🚀 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.
2666 lines
124 KiB
Swift
2666 lines
124 KiB
Swift
import SwiftUI
|
||
import UniformTypeIdentifiers // Nodig voor UTType.image
|
||
import Vision // Voor OCR functionaliteit
|
||
|
||
// MARK: - Image Store (ObservableObject)
|
||
class GalleryImageStore: ObservableObject {
|
||
@Published var images: [IdentifiableImage] = []
|
||
|
||
// NIEUW: Permanent stash directory
|
||
private static let stashDirectoryName = "StashItems"
|
||
|
||
// NIEUW: Initialization flag
|
||
private var hasInitialized = false
|
||
|
||
// NIEUW: Set om bij te houden welke afbeeldingen momenteel worden toegevoegd
|
||
private var processingIdentifiers = Set<String>()
|
||
|
||
init() {
|
||
// Cleanup oude stash bestanden bij opstarten
|
||
if !hasInitialized {
|
||
cleanupStashDirectory()
|
||
hasInitialized = true
|
||
}
|
||
}
|
||
|
||
private var stashDirectory: URL {
|
||
let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||
let appDirectory = appSupportURL.appendingPathComponent("ShotScreen")
|
||
let stashDir = appDirectory.appendingPathComponent(Self.stashDirectoryName)
|
||
|
||
// Zorg ervoor dat de directory bestaat
|
||
try? FileManager.default.createDirectory(at: stashDir, withIntermediateDirectories: true, attributes: nil)
|
||
|
||
return stashDir
|
||
}
|
||
|
||
// NIEUW: Public accessor voor stash directory
|
||
var publicStashDirectory: URL {
|
||
return self.stashDirectory
|
||
}
|
||
|
||
// NIEUW: Helper functie om een unieke copy naam te genereren
|
||
func generateUniqueCopyName(baseName: String) -> String {
|
||
let existingNames = Set(images.compactMap { item in
|
||
item.fileURL?.deletingPathExtension().lastPathComponent ?? item.customName
|
||
})
|
||
|
||
// Probeer eerst zonder nummer
|
||
let firstTry = "\(baseName) Copy"
|
||
if !existingNames.contains(firstTry) {
|
||
return firstTry
|
||
}
|
||
|
||
// Als die bestaat, probeer met nummers
|
||
var counter = 2
|
||
while counter <= 999 { // Voorkom oneindige loop
|
||
let attempt = "\(baseName) Copy \(counter)"
|
||
if !existingNames.contains(attempt) {
|
||
return attempt
|
||
}
|
||
counter += 1
|
||
}
|
||
|
||
// Fallback als er teveel copies zijn
|
||
return "\(baseName) Copy \(Int(Date().timeIntervalSince1970) % 10000)"
|
||
}
|
||
|
||
func addImage(_ nsImage: NSImage, fileURL: URL? = nil, suggestedName: String? = nil, skipDuplicateCheck: Bool = false) {
|
||
let callId = UUID().uuidString.prefix(4)
|
||
print("🖼️ [\(callId)] addImage START. fileURL: \(fileURL?.lastPathComponent ?? "nil"), suggestedName: '\(suggestedName ?? "nil")', nsImage size: \(nsImage.size), skipDuplicateCheck: \(skipDuplicateCheck)")
|
||
|
||
// Step 1: Generate unique identifier
|
||
let imageIdentifier = generateImageIdentifier(from: nsImage, fileURL: fileURL, suggestedName: suggestedName, callId: String(callId))
|
||
|
||
// Step 2: Check if already processing
|
||
guard !processingIdentifiers.contains(imageIdentifier) else {
|
||
print("🖼️ [\(callId)] GalleryImageStore: Afbeelding met identifier '\(imageIdentifier.prefix(50))...' wordt AL VERWERKT. Toevoegen overgeslagen.")
|
||
return
|
||
}
|
||
|
||
// Step 3: Mark as processing
|
||
processingIdentifiers.insert(imageIdentifier)
|
||
print("⏳ [\(callId)] GalleryImageStore: Identifier '\(imageIdentifier.prefix(50))...' toegevoegd aan processing set.")
|
||
|
||
// Step 4: Process on main queue
|
||
DispatchQueue.main.async {
|
||
self.processImageAddition(nsImage: nsImage, fileURL: fileURL, suggestedName: suggestedName,
|
||
skipDuplicateCheck: skipDuplicateCheck, imageIdentifier: imageIdentifier, callId: String(callId))
|
||
}
|
||
}
|
||
|
||
// MARK: - Private Helper Methods for addImage
|
||
|
||
private func generateImageIdentifier(from nsImage: NSImage, fileURL: URL?, suggestedName: String?, callId: String) -> String {
|
||
if let url = fileURL, url.path.contains(self.stashDirectory.path) {
|
||
let identifier = url.path
|
||
print("🖼️ [\(callId)] Generated imageIdentifier (from stash path): \(identifier)")
|
||
return identifier
|
||
} else if let tiffData = nsImage.tiffRepresentation {
|
||
let identifier = "tiff_\(tiffData.count)_\(suggestedName ?? "no_name")_\(fileURL?.lastPathComponent ?? "no_url")"
|
||
print("🖼️ [\(callId)] Generated imageIdentifier (from TIFF data): \(identifier)")
|
||
return identifier
|
||
} else {
|
||
print("⚠️ [\(callId)] GalleryImageStore: Kan geen unieke identifier voor afbeelding genereren. Toevoegen overgeslagen.")
|
||
return "failed_\(callId)"
|
||
}
|
||
}
|
||
|
||
private func processImageAddition(nsImage: NSImage, fileURL: URL?, suggestedName: String?,
|
||
skipDuplicateCheck: Bool, imageIdentifier: String, callId: String) {
|
||
print("🖼️ [\(callId)] DispatchQueue.main.async - START check images.contains")
|
||
|
||
// Step 1: Check for duplicates (unless skipped)
|
||
let alreadyInStore = checkForDuplicates(nsImage: nsImage, fileURL: fileURL,
|
||
skipDuplicateCheck: skipDuplicateCheck, callId: callId)
|
||
|
||
if alreadyInStore {
|
||
print("🖼️ [\(callId)] Afbeelding BESTAAT AL in de store. Verwijder van processing set.")
|
||
self.processingIdentifiers.remove(imageIdentifier)
|
||
print("⌛ [\(callId)] Identifier '\(imageIdentifier.prefix(50))...' verwijderd van processing set (bestond al).")
|
||
return
|
||
}
|
||
|
||
// Step 2: Create permanent URL if needed
|
||
guard let permanentURL = ensurePermanentURL(nsImage: nsImage, fileURL: fileURL,
|
||
suggestedName: suggestedName, callId: callId) else {
|
||
print("❌ [\(callId)] GalleryImageStore: Kon geen permanente URL verkrijgen/maken. Verwijder van processing set.")
|
||
self.processingIdentifiers.remove(imageIdentifier)
|
||
print("⌛ [\(callId)] Identifier '\(imageIdentifier.prefix(50))...' verwijderd van processing set (URL error).")
|
||
return
|
||
}
|
||
|
||
// Step 3: Create and add the new item
|
||
let newItem = IdentifiableImage(nsImage: nsImage, fileURL: permanentURL, customName: suggestedName)
|
||
print("🖼️ [\(callId)] Nieuw IdentifiableImage aangemaakt: ID \(newItem.id.uuidString.prefix(4)), URL: \(newItem.fileURL?.lastPathComponent ?? "nil"), Name: \(newItem.customName ?? "nil")")
|
||
|
||
self.images.append(newItem)
|
||
print("🖼️ [\(callId)] Afbeelding toegevoegd. URL: \(permanentURL.lastPathComponent). Nieuwe count: \(self.images.count). Verwijder van processing set.")
|
||
self.processingIdentifiers.remove(imageIdentifier)
|
||
print("⌛ [\(callId)] Identifier '\(imageIdentifier.prefix(50))...' verwijderd van processing set (succesvol toegevoegd).")
|
||
}
|
||
|
||
private func checkForDuplicates(nsImage: NSImage, fileURL: URL?, skipDuplicateCheck: Bool, callId: String) -> Bool {
|
||
if skipDuplicateCheck {
|
||
print("🔥 [\(callId)] ULTRA MODE: Skipping duplicate check for intentional duplicate")
|
||
return false
|
||
}
|
||
|
||
return self.images.contains { existingItem in
|
||
print("🖼️ [\(callId)] Checking existingItem: ID \(existingItem.id.uuidString.prefix(4)), URL: \(existingItem.fileURL?.lastPathComponent ?? "nil"), Name: \(existingItem.customName ?? "nil")")
|
||
|
||
// Check URL match
|
||
if let existingURL = existingItem.fileURL, let newURL = fileURL,
|
||
existingURL.standardizedFileURL == newURL.standardizedFileURL {
|
||
print("🖼️ [\(callId)] Match on standardizedFileURL: \(newURL.path)")
|
||
return true
|
||
}
|
||
|
||
// Check TIFF data match
|
||
if let d1 = existingItem.nsImage.tiffRepresentation,
|
||
let d2 = nsImage.tiffRepresentation, d1 == d2 {
|
||
print("🖼️ [\(callId)] Match on TIFF data.")
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
}
|
||
|
||
private func ensurePermanentURL(nsImage: NSImage, fileURL: URL?, suggestedName: String?, callId: String) -> URL? {
|
||
var permanentURLToUse: URL? = fileURL
|
||
|
||
if permanentURLToUse == nil ||
|
||
!FileManager.default.fileExists(atPath: permanentURLToUse!.path) ||
|
||
!permanentURLToUse!.path.contains(self.stashDirectory.path) {
|
||
|
||
var finalSuggestedName = suggestedName
|
||
if finalSuggestedName == nil || finalSuggestedName?.isEmpty == true {
|
||
if let providedURL = fileURL {
|
||
finalSuggestedName = providedURL.deletingPathExtension().lastPathComponent
|
||
} else {
|
||
let dateFormatter = DateFormatter()
|
||
dateFormatter.dateFormat = "yyyy-MM-dd_HH.mm.ss.SSS"
|
||
finalSuggestedName = "Image_\(dateFormatter.string(from: Date()))"
|
||
}
|
||
print("🖼️ [\(callId)] Geen/ongeldige naam of URL, gebruik (herleide) naam: '\(finalSuggestedName ?? "default_name")'")
|
||
}
|
||
|
||
let nameForURLCreation = finalSuggestedName ?? "default_for_url_\(callId)"
|
||
print("🖼️ [\(callId)] Maak permanente URL met effective name: '\(nameForURLCreation)'")
|
||
permanentURLToUse = self.createPermanentStashURL(for: nsImage, suggestedName: nameForURLCreation, useOriginalName: true)
|
||
}
|
||
|
||
return permanentURLToUse
|
||
}
|
||
|
||
// NIEUW: Maak een permanente URL voor stash items
|
||
private func createPermanentStashURL(for nsImage: NSImage, suggestedName: String?, useOriginalName: Bool) -> URL? {
|
||
guard let tiffRepresentation = nsImage.tiffRepresentation,
|
||
let bitmapImageRep = NSBitmapImageRep(data: tiffRepresentation),
|
||
let pngData = bitmapImageRep.representation(using: .png, properties: [:]) else {
|
||
print("❌ Could not create permanent stash URL: image conversion failed")
|
||
return nil
|
||
}
|
||
|
||
// FIXED: Gebruik originele naam als useOriginalName true is en suggestedName bestaat
|
||
let filename: String
|
||
if useOriginalName, let originalName = suggestedName, !originalName.isEmpty {
|
||
// Gebruik originele naam met .png extensie
|
||
let baseFilename = "\(originalName).png"
|
||
|
||
// Check voor duplicate namen
|
||
var finalFilename = baseFilename
|
||
var counter = 1
|
||
while FileManager.default.fileExists(atPath: stashDirectory.appendingPathComponent(finalFilename).path) {
|
||
finalFilename = "\(originalName) (\(counter)).png"
|
||
counter += 1
|
||
if counter > 100 {
|
||
finalFilename = baseFilename
|
||
break
|
||
}
|
||
}
|
||
|
||
filename = finalFilename
|
||
} else {
|
||
// Default naar timestamp-based filename
|
||
let timestamp = Int(Date().timeIntervalSince1970)
|
||
let uuid = UUID().uuidString.prefix(8)
|
||
filename = "StashItem_\(timestamp)_\(uuid).png"
|
||
}
|
||
|
||
let permanentURL = stashDirectory.appendingPathComponent(filename)
|
||
|
||
do {
|
||
try pngData.write(to: permanentURL)
|
||
print("✅ Created permanent stash URL: \(permanentURL.lastPathComponent)")
|
||
return permanentURL
|
||
} catch {
|
||
print("❌ Could not create permanent stash URL: \(error)")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// Helper om de store leeg te maken en een initiële afbeelding in te stellen (optioneel)
|
||
func setInitialImage(_ nsImage: NSImage?) {
|
||
DispatchQueue.main.async {
|
||
self.images.removeAll()
|
||
if let img = nsImage {
|
||
// AANGEPAST: Ook initiële image krijgt permanente URL
|
||
let permanentURL = self.createPermanentStashURL(for: img, suggestedName: nil, useOriginalName: true)
|
||
self.images.append(IdentifiableImage(nsImage: img, fileURL: permanentURL))
|
||
}
|
||
print("🖼️ GalleryImageStore: Initial image set. Count: \(self.images.count)")
|
||
}
|
||
}
|
||
|
||
func removeImage(_ imageItem: IdentifiableImage) {
|
||
// NIEUW: Verwijder ook het permanente bestand
|
||
if let fileURL = imageItem.fileURL {
|
||
try? FileManager.default.removeItem(at: fileURL)
|
||
print("🗑️ Removed permanent stash file: \(fileURL.lastPathComponent)")
|
||
}
|
||
|
||
self.images.removeAll { $0.id == imageItem.id }
|
||
print("🖼️ GalleryImageStore: Image removed. New count: \(self.images.count)")
|
||
}
|
||
|
||
func renameImage(item: IdentifiableImage, newName: String) -> Bool {
|
||
guard let index = images.firstIndex(where: { $0.id == item.id }) else {
|
||
return false
|
||
}
|
||
|
||
// NIEUW: Update ook de filename van het permanente bestand
|
||
if let oldURL = item.fileURL {
|
||
let newFilename = "\(newName).png"
|
||
let newURL = oldURL.deletingLastPathComponent().appendingPathComponent(newFilename)
|
||
|
||
do {
|
||
try FileManager.default.moveItem(at: oldURL, to: newURL)
|
||
// Update het item met de nieuwe URL en naam
|
||
images[index].fileURL = newURL
|
||
images[index].customName = newName
|
||
print("✅ Renamed stash file from \(oldURL.lastPathComponent) to \(newURL.lastPathComponent)")
|
||
return true
|
||
} catch {
|
||
print("❌ Failed to rename stash file: \(error)")
|
||
// Als bestand hernoemen faalt, update alleen de customName
|
||
images[index].customName = newName
|
||
print("ℹ️ Updated display name only (file rename failed)")
|
||
return true
|
||
}
|
||
} else {
|
||
// Geen fileURL, update alleen de customName
|
||
images[index].customName = newName
|
||
print("ℹ️ Updated display name only (no file URL)")
|
||
return true
|
||
}
|
||
}
|
||
|
||
// Methode om een item te verversen (bijv. na inline bewerking zoals BG remove)
|
||
func refreshItem(_ item: IdentifiableImage) {
|
||
// Dit is een placeholder. Afhankelijk van hoe IdentifiableImage en SwiftUI updates werken,
|
||
// kan het zijn dat je het object moet vervangen of simpelweg de array moet publiceren.
|
||
// Als IdentifiableImage een class is en @Published wordt gebruikt, kan een simpele property change voldoende zijn.
|
||
// Als het een struct is, moet je het item in de 'images' array vervangen.
|
||
if let index = images.firstIndex(where: { $0.id == item.id }) {
|
||
// Forceer een update door het object (of een kopie met dezelfde id) opnieuw toe te wijzen
|
||
// Dit is een beetje een hack, een beter ObservableObject-patroon is aanbevolen.
|
||
let updatedItem = images[index] // Neem aan dat het een class is en de interne state is gewijzigd
|
||
images.remove(at: index)
|
||
images.insert(updatedItem, at: index)
|
||
print("🔄 GalleryImageStore: Refreshed item \(item.id)")
|
||
}
|
||
}
|
||
|
||
// NIEUW: Cleanup functie om oude stash bestanden te verwijderen
|
||
func cleanupStashDirectory() {
|
||
do {
|
||
let contents = try FileManager.default.contentsOfDirectory(at: stashDirectory, includingPropertiesForKeys: [.creationDateKey])
|
||
|
||
// Verwijder bestanden die niet meer in de images array staan
|
||
let activeURLs = Set(images.compactMap { $0.fileURL })
|
||
|
||
for fileURL in contents {
|
||
if !activeURLs.contains(fileURL) {
|
||
try? FileManager.default.removeItem(at: fileURL)
|
||
print("🧹 Cleaned up orphaned stash file: \(fileURL.lastPathComponent)")
|
||
}
|
||
}
|
||
} catch {
|
||
print("⚠️ Could not cleanup stash directory: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Hulpstructuur om NSImage identificeerbaar te maken voor de ForEach
|
||
struct IdentifiableImage: Identifiable {
|
||
let id = UUID()
|
||
let nsImage: NSImage
|
||
var fileURL: URL? = nil // AANGEPAST: nu altijd een permanente URL voor stash items
|
||
var customName: String? = nil // huidige naam (kan via rename wijzigen)
|
||
}
|
||
|
||
// MARK: - Window Dragging Support
|
||
// Deze NSViewRepresentable host onze CustomDragNSView
|
||
struct WindowDragView: NSViewRepresentable {
|
||
func makeNSView(context: Context) -> CustomDragNSView {
|
||
return CustomDragNSView()
|
||
}
|
||
|
||
func updateNSView(_ nsView: CustomDragNSView, context: Context) {
|
||
// Geen updates nodig voor nu
|
||
}
|
||
}
|
||
|
||
// Deze NSView vangt muis events af om het venster te kunnen slepen
|
||
class CustomDragNSView: NSView {
|
||
private var mouseDownScreenLocation: NSPoint?
|
||
private var initialWindowOrigin: NSPoint?
|
||
|
||
override func mouseDown(with event: NSEvent) {
|
||
guard let window = self.window else { return }
|
||
self.mouseDownScreenLocation = NSEvent.mouseLocation
|
||
self.initialWindowOrigin = window.frame.origin
|
||
}
|
||
|
||
override func mouseDragged(with event: NSEvent) {
|
||
guard let window = self.window,
|
||
let mouseDownScreenLoc = self.mouseDownScreenLocation,
|
||
let initialWinOrigin = self.initialWindowOrigin else { return }
|
||
|
||
let currentMouseScreenLocation = NSEvent.mouseLocation
|
||
let deltaX = currentMouseScreenLocation.x - mouseDownScreenLoc.x
|
||
let deltaY = currentMouseScreenLocation.y - mouseDownScreenLoc.y
|
||
|
||
let newWindowOriginX = initialWinOrigin.x + deltaX
|
||
let newWindowOriginY = initialWinOrigin.y + deltaY
|
||
|
||
window.setFrameOrigin(NSPoint(x: newWindowOriginX, y: newWindowOriginY))
|
||
}
|
||
|
||
override func mouseUp(with event: NSEvent) {
|
||
self.mouseDownScreenLocation = nil
|
||
self.initialWindowOrigin = nil
|
||
}
|
||
}
|
||
|
||
// Representable voor NSVisualEffectView zodat we hem in SwiftUI kunnen gebruiken
|
||
struct VisualEffectBackground: NSViewRepresentable {
|
||
var material: NSVisualEffectView.Material = .hudWindow
|
||
var blending: NSVisualEffectView.BlendingMode = .behindWindow
|
||
var alpha: CGFloat = 1.0
|
||
|
||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||
let view = NSVisualEffectView()
|
||
view.material = material
|
||
view.blendingMode = blending
|
||
view.state = .active
|
||
view.alphaValue = alpha
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
|
||
nsView.material = material
|
||
nsView.blendingMode = blending
|
||
nsView.alphaValue = alpha
|
||
}
|
||
}
|
||
|
||
struct IntegratedGalleryView: View {
|
||
@ObservedObject var imageStore: GalleryImageStore
|
||
let initialImage: NSImage?
|
||
let initialImageURL: URL?
|
||
let initialImageName: String?
|
||
let hostingWindow: NSWindow?
|
||
let closeAction: () -> Void
|
||
|
||
// MARK: - State Variables
|
||
@State private var showPreview = false
|
||
@State private var hoveredImageID: UUID? = nil
|
||
@State private var targetedImageID: UUID? = nil // <-- TOEGEVOEGD VOOR PRECISIE
|
||
@State private var isTargeted = false
|
||
@State private var windowWidth: CGFloat = 200 // Default, wordt bijgewerkt
|
||
@State private var windowHeight: CGFloat = 300 // Default, wordt bijgewerkt
|
||
@State private var refreshTrigger = 0
|
||
@State private var isLayoutDebugEnabled = false
|
||
private let debugUpdateInterval = 1.0
|
||
@State private var timer: Timer?
|
||
@State private var refreshPreviewTimer: Timer?
|
||
|
||
// 🔥 NIEUW: Drop Zone fade state
|
||
@State private var dropZoneOpacity: Double = 1.0
|
||
@State private var dropZoneFadeTimer: Timer?
|
||
|
||
// MARK: - More State Variables
|
||
@State private var currentPreviewName: String = "Unknown" // TURBO FIX: Instant naam tracking
|
||
@State private var previewNameRefreshTimer: Timer? // ⏰ TIMER FORCE REFRESH
|
||
@State private var imageForPreview: NSImage?
|
||
@State private var isPreviewStable: Bool = false
|
||
@State private var hoverWorkItem: DispatchWorkItem?
|
||
|
||
// State voor het preview venster
|
||
@State private var isClosingGracefully = false
|
||
@State private var previewWindowController: NSWindowController? = nil
|
||
|
||
// NIEUW: Stable state management to prevent excessive re-rendering
|
||
@State private var renderIsolationID = UUID()
|
||
|
||
@State private var didGridHandleDrop: Bool = false
|
||
|
||
// ID voor de view
|
||
@State private var _galleryID = UUID()
|
||
var galleryID: UUID { _galleryID }
|
||
|
||
// NIEUW: Cache voor StashDragDelegate instances
|
||
@State private var dragDelegateCache: [UUID: StashDragDelegate] = [:]
|
||
|
||
// CRITICAL FIX: Stable delegate cache that persists across renders
|
||
@State private var stableDelegateCache: [UUID: StashDragDelegate] = [:]
|
||
|
||
// CRITICAL FIX: Prevent cascade re-renders with render isolation
|
||
@State private var lastImageCount: Int = 0
|
||
|
||
// Instellingen voor de grid en venstergrootte
|
||
private let cellSpacing: CGFloat = 8
|
||
private let paddingAroundGrid: CGFloat = 5
|
||
private let titleBarHeight: CGFloat = 18
|
||
private let defaultEmptyWidth: CGFloat = 100
|
||
private let defaultEmptyHeight: CGFloat = 100
|
||
|
||
private var calculatedThumbnailSize: CGFloat {
|
||
let baseSize: CGFloat = 70
|
||
return baseSize
|
||
}
|
||
private let thumbnailCornerRadius: CGFloat = 4
|
||
private let hoverScaleEffect: CGFloat = 1.03
|
||
|
||
// Haal border width op uit SettingsManager (wordt nu automatisch geüpdatet door @ObservedObject)
|
||
// private var stashBorderWidth: CGFloat { settings.stashWindowBorderWidth } // Deze kan weg of blijven
|
||
// private var stashBorderColor: Color { Color.gray.opacity(0.5) } // Deze kan weg of blijven
|
||
|
||
private var gridColumns: [GridItem] {
|
||
Array(repeating: GridItem(.flexible(), spacing: cellSpacing), count: actualNumberOfColumns())
|
||
}
|
||
|
||
// Initializer om de store te ontvangen
|
||
// De initialImage parameter is voor het gemak om de store direct te vullen bij creatie.
|
||
init(imageStore: GalleryImageStore, initialImage: NSImage? = nil, initialImageURL: URL? = nil, initialImageName: String? = nil, hostingWindow: NSWindow?, closeAction: @escaping () -> Void) {
|
||
let callId = UUID().uuidString.prefix(4)
|
||
print("🎨 [\(callId)] IntegratedGalleryView.INIT CALLED.")
|
||
print("🎨 [\(callId)] imageStore.images.count: \(imageStore.images.count)")
|
||
print("🎨 [\(callId)] initialImage isNil: \(initialImage == nil)")
|
||
if let img = initialImage { print("🎨 [\(callId)] initialImage size: \(img.size)") }
|
||
print("🎨 [\(callId)] initialImageURL: \(initialImageURL?.lastPathComponent ?? "nil")")
|
||
print("🎨 [\(callId)] initialImageName: '\(initialImageName ?? "nil")'")
|
||
print("🎨 [\(callId)] hostingWindow isNil: \(hostingWindow == nil)")
|
||
|
||
self.imageStore = imageStore
|
||
self.initialImage = initialImage
|
||
self.initialImageURL = initialImageURL
|
||
self.initialImageName = initialImageName
|
||
self.hostingWindow = hostingWindow
|
||
self.closeAction = closeAction
|
||
|
||
if let imgToAdd = initialImage {
|
||
let imageNameForLog = initialImageName ?? initialImageURL?.lastPathComponent ?? "Unnamed InitialImage (from init)"
|
||
print("🎨 [\(callId)] Overweegt initialImage '\(imageNameForLog)' voor toevoeging.")
|
||
|
||
var alreadyExistsReason = "None"
|
||
let alreadyExists = imageStore.images.contains { existingItem in
|
||
print("🎨 [\(callId)] INIT: Comparing with existingItem ID \(existingItem.id.uuidString.prefix(4)), URL: \(existingItem.fileURL?.lastPathComponent ?? "nil"), Name: \(existingItem.customName ?? "nil")")
|
||
if let newURL = initialImageURL, newURL.isFileURL,
|
||
let existingURL = existingItem.fileURL, existingURL.isFileURL,
|
||
newURL.standardizedFileURL == existingURL.standardizedFileURL {
|
||
if FileManager.default.fileExists(atPath: newURL.path) {
|
||
print("🎨 [\(callId)] INIT: Match on standardizedFileURL: \(newURL.path)")
|
||
alreadyExistsReason = "Matched standardizedFileURL: \(newURL.path)"
|
||
return true
|
||
}
|
||
}
|
||
if let newTIFF = imgToAdd.tiffRepresentation, let existingTIFF = existingItem.nsImage.tiffRepresentation, newTIFF == existingTIFF {
|
||
print("🎨 [\(callId)] INIT: Match on TIFF data.")
|
||
alreadyExistsReason = "Matched TIFF data"
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
print("🎨 [\(callId)] INIT: alreadyExists check result: \(alreadyExists). Reason: \(alreadyExistsReason)")
|
||
|
||
if !alreadyExists {
|
||
print("✅ [\(callId)] INIT: InitialImage '\(imageNameForLog)' wordt toegevoegd aan store.")
|
||
// 🔥 CRITICAL FIX: Skip duplicate check for initial stash images to allow BGR duplicates
|
||
imageStore.addImage(imgToAdd, fileURL: initialImageURL, suggestedName: initialImageName, skipDuplicateCheck: true)
|
||
} else {
|
||
print("ℹ️ [\(callId)] INIT: InitialImage '\(imageNameForLog)' overgeslagen - bestaat al (Reason: \(alreadyExistsReason)).")
|
||
}
|
||
} else {
|
||
print("ℹ️ [\(callId)] INIT: Geen initialImage meegegeven.")
|
||
}
|
||
print("🎨 [\(callId)] IntegratedGalleryView.INIT COMPLETED. imageStore.images.count: \(self.imageStore.images.count)")
|
||
}
|
||
|
||
var body: some View {
|
||
// CRITICAL FIX: Render isolation - only log significant changes
|
||
let currentImageCount = imageStore.images.count
|
||
let shouldLogRender = currentImageCount != lastImageCount
|
||
let _ = shouldLogRender ? print("🎨 IntegratedGalleryView.BODY: SIGNIFICANT CHANGE - Image count: \(currentImageCount) -> \(lastImageCount)") : ()
|
||
|
||
ZStack { // Root ZStack
|
||
if isClosingGracefully {
|
||
EmptyView()
|
||
} else {
|
||
VStack(spacing: 0) {
|
||
// Custom Title Bar - ULTRA FIX: Altijd zichtbaar voor consistente layout
|
||
HStack {
|
||
Button(action: {
|
||
self.closeAction()
|
||
}) {
|
||
Image(systemName: "xmark")
|
||
.foregroundColor(Color.adaptivePrimaryText)
|
||
.font(.system(size: 10, weight: .bold))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.horizontal, 10)
|
||
|
||
Spacer()
|
||
|
||
// 🎯 NIEUW: Shake Test Button - NU RECHTS UITGELIJND
|
||
Button(action: {
|
||
testShakeAnimation()
|
||
}) {
|
||
Image(systemName: "bolt.fill")
|
||
.foregroundColor(.yellow)
|
||
.font(.system(size: 10, weight: .bold))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.horizontal, 10)
|
||
.help("Test Shake Animation")
|
||
}
|
||
.frame(height: titleBarHeight)
|
||
.background(
|
||
ZStack {
|
||
VisualEffectBackground(alpha: 0.8)
|
||
.cornerRadius(8)
|
||
VisualEffectBackground(alpha: 0.4)
|
||
.cornerRadius(8)
|
||
WindowDragView()
|
||
}
|
||
)
|
||
.transition(.opacity)
|
||
|
||
// Content Area - ULTRA FIX: Altijd dezelfde layout
|
||
VStack(spacing: 0) {
|
||
consistentImageGridView
|
||
permanentFileDropZone
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
}
|
||
.frame(width: effectiveWindowWidth(), height: effectiveWindowHeight())
|
||
.background(Color.clear)
|
||
.cornerRadius(8)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(Color.clear, lineWidth: 0)
|
||
)
|
||
.animation(.easeInOut(duration: 0.2), value: imageStore.images.isEmpty)
|
||
.onChange(of: showPreview) { newValue in
|
||
handlePreviewWindowState(shouldShow: newValue)
|
||
}
|
||
.onChange(of: imageForPreview) { newImage in
|
||
if showPreview, let _ = newImage {
|
||
handlePreviewWindowState(shouldShow: true)
|
||
}
|
||
}
|
||
.onChange(of: imageStore.images.count) { newCount in
|
||
// CRITICAL FIX: Update tracking variables
|
||
DispatchQueue.main.async {
|
||
if self.lastImageCount != newCount {
|
||
self.lastImageCount = newCount
|
||
self.renderIsolationID = UUID()
|
||
print("🔄 CRITICAL: Image count changed to \(newCount), updated render isolation")
|
||
|
||
// 🔥 MEGA ULTRA FIX: If count increased and we're showing preview, update to show newest item
|
||
if newCount > self.lastImageCount && self.showPreview && self.imageStore.images.count > 0 {
|
||
if let newestItem = self.imageStore.images.last {
|
||
let newestName = newestItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown"
|
||
print("🔥 MEGA: New item detected during preview! Updating name to: '\(newestName)'")
|
||
self.currentPreviewName = newestName
|
||
|
||
// Force immediate preview update
|
||
self.forceRefreshPreviewName()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||
closePreviewWindow()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .stashPreviewSizeChanged)) { _ in
|
||
// 🔥💎 MEGA LIVE UPDATE VOOR PREVIEW SIZE! 💎🔥
|
||
print("🔥 STASH: Preview size setting changed - will apply on next hover")
|
||
// De nieuwe size wordt automatisch toegepast bij de volgende hover omdat we
|
||
// SettingsManager.shared.stashPreviewSize.percentage gebruiken in de size calculations
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .stashGridModeChanged)) { _ in
|
||
// 🔥💥⚡ HYPERMODE GRID MODE LIVE UPDATE! ⚡💥🔥
|
||
print("🔥 HYPERMODE: Grid mode changed - triggering layout update")
|
||
DispatchQueue.main.async {
|
||
self.renderIsolationID = UUID() // Force render update
|
||
}
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .stashGridConfigChanged)) { _ in
|
||
// 🔥💥⚡ HYPERMODE GRID CONFIG LIVE UPDATE! ⚡💥🔥
|
||
print("🔥 HYPERMODE: Grid configuration changed - triggering layout update")
|
||
DispatchQueue.main.async {
|
||
self.renderIsolationID = UUID() // Force render update
|
||
// Force window size recalculation by updating hosting window
|
||
if let window = self.hostingWindow {
|
||
let newWidth = self.effectiveWindowWidth()
|
||
let newHeight = self.effectiveWindowHeight()
|
||
let newSize = NSSize(width: newWidth, height: newHeight)
|
||
print("🔥 HYPERMODE: Updating window size to \(newWidth) x \(newHeight)")
|
||
window.setContentSize(newSize)
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
self.lastImageCount = imageStore.images.count
|
||
print("🔶 IntegratedGalleryView: onAppear - initialized with \(imageStore.images.count) images")
|
||
}
|
||
.onDisappear {
|
||
closePreviewWindow()
|
||
stopPreviewNameRefreshTimer() // ⏰ Stop timer on disappear
|
||
dropZoneFadeTimer?.invalidate() // 🔥 CLEANUP FADE TIMER
|
||
imageStore.cleanupStashDirectory()
|
||
}
|
||
.padding(2)
|
||
.onChange(of: hoveredImageID) { _ in
|
||
// 🏎️ SUPER FAST HOVER FIX: Whenever hoveredImageID changes while preview is visible, force an immediate update.
|
||
if showPreview {
|
||
print("⚡ FAST HOVER: hoveredImageID changed, forcing preview update")
|
||
handlePreviewWindowState(shouldShow: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Subviews
|
||
|
||
// NIEUW: Permanente file drop zone die altijd zichtbaar is + FADE EFFECT
|
||
private var permanentFileDropZone: some View {
|
||
HStack {
|
||
Spacer()
|
||
|
||
Text("Drop Zone")
|
||
.font(.caption)
|
||
.fontWeight(.medium)
|
||
.italic() // Cursief
|
||
.foregroundColor(.secondary) // TERUG NAAR WIT voor contrast
|
||
|
||
Spacer()
|
||
}
|
||
.background(isTargeted ? Color.blue.opacity(0.5) : Color.blue.opacity(0.4)) // Donkerblauw in plaats van grijs
|
||
.clipShape(
|
||
UnevenRoundedRectangle(
|
||
topLeadingRadius: 0,
|
||
bottomLeadingRadius: 6,
|
||
bottomTrailingRadius: 6,
|
||
topTrailingRadius: 0
|
||
)
|
||
)
|
||
.opacity(dropZoneOpacity) // 🔥 FADE EFFECT
|
||
.onDrop(of: [UTType.fileURL], isTargeted: .constant(false)) { providers -> Bool in
|
||
handleFileOnlyDrop(providers: providers)
|
||
return true
|
||
}
|
||
.onAppear {
|
||
print("⏰ Drop Zone: permanentFileDropZone.onAppear TRIGGERED!")
|
||
startDropZoneFadeTimer()
|
||
}
|
||
// VERWIJDERD: Alle padding weggehaald voor directe verbinding met thumbnails
|
||
}
|
||
|
||
private var dropZoneView: some View {
|
||
VStack(spacing: 12) {
|
||
// NIEWE: Speciale file drop zone die originele namen behoudt
|
||
VStack(spacing: 6) {
|
||
Image(systemName: "doc.on.doc")
|
||
.font(.system(size: 20))
|
||
.foregroundColor(.blue.opacity(0.4)) // Veel donkerder blauw
|
||
|
||
Text("Sleep bestanden hier")
|
||
.font(.caption2)
|
||
.fontWeight(.medium)
|
||
|
||
Text("Behoudt originele naam")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 60) // Veel lager van 80 naar 60
|
||
.background(isTargeted ? Color.blue.opacity(0.4) : Color.blue.opacity(0.3)) // Veel donkerdere achtergrond
|
||
.cornerRadius(8)
|
||
.onDrop(of: [UTType.fileURL], isTargeted: .constant(false)) { providers -> Bool in
|
||
handleFileOnlyDrop(providers: providers)
|
||
return true
|
||
}
|
||
|
||
Text("of")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
|
||
// Bestaande algemene drop zone
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "photo.on.rectangle")
|
||
.font(.system(size: 20))
|
||
.foregroundColor(.gray)
|
||
|
||
Text("Sleep afbeeldingen hier")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 60)
|
||
.background(isTargeted ? Color.gray.opacity(0.15) : Color.gray.opacity(0.05))
|
||
.cornerRadius(8)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.strokeBorder(isTargeted ? Color.gray.opacity(0.5) : Color.gray.opacity(0.3), style: StrokeStyle(lineWidth: 1, dash: [4]))
|
||
)
|
||
.onDrop(of: [UTType.image], isTargeted: $isTargeted) { providers -> Bool in
|
||
handleDrop(providers: providers)
|
||
return true
|
||
}
|
||
}
|
||
.padding(paddingAroundGrid)
|
||
}
|
||
|
||
private var consistentImageGridView: some View {
|
||
ScrollView(.vertical, showsIndicators: false) {
|
||
// CRITICAL FIX: Only log when count actually changes
|
||
let currentCount = imageStore.images.count
|
||
let _ = currentCount != lastImageCount ? print("🎨 GRID: Actual count change detected: \(lastImageCount) -> \(currentCount)") : ()
|
||
|
||
LazyVGrid(columns: gridColumns, spacing: cellSpacing) {
|
||
if imageStore.images.isEmpty {
|
||
// ULTRA FIX: Placeholder that looks like a thumbnail for consistent layout
|
||
emptyStatePlaceholder
|
||
} else {
|
||
ForEach(imageStore.images) { imgItem in
|
||
OptimizedStashItemView(
|
||
imageItem: imgItem,
|
||
calculatedThumbnailSize: calculatedThumbnailSize,
|
||
thumbnailCornerRadius: thumbnailCornerRadius,
|
||
hoverScaleEffect: hoverScaleEffect,
|
||
hoveredImageID: $hoveredImageID,
|
||
currentPreviewName: $currentPreviewName,
|
||
imageForPreview: $imageForPreview,
|
||
showPreview: $showPreview,
|
||
isPreviewStable: $isPreviewStable,
|
||
hoverWorkItem: $hoverWorkItem,
|
||
stableDelegateCache: $stableDelegateCache,
|
||
imageStore: imageStore,
|
||
onRemove: { removedItem in
|
||
stableDelegateCache.removeValue(forKey: removedItem.id)
|
||
imageStore.removeImage(removedItem)
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, paddingAroundGrid)
|
||
.padding(.top, paddingAroundGrid)
|
||
}
|
||
.coordinateSpace(name: "GalleryScrollViewSpace")
|
||
}
|
||
|
||
// ULTRA FIX: Placeholder die er precies zo uitziet als een echte thumbnail
|
||
private var emptyStatePlaceholder: some View {
|
||
// ULTRA FIX: Helemaal leeg, 100% transparant
|
||
Rectangle()
|
||
.fill(Color.clear)
|
||
.frame(width: calculatedThumbnailSize, height: calculatedThumbnailSize)
|
||
.background(Color.clear) // 100% transparant zoals gevraagd
|
||
.cornerRadius(thumbnailCornerRadius)
|
||
.onDrop(of: [UTType.fileURL], isTargeted: $isTargeted) { providers -> Bool in
|
||
// ULTRA FIX: Alleen handleFileOnlyDrop voor consistente naamgeving (net als onderste dropzone)
|
||
handleFileOnlyDrop(providers: providers)
|
||
return true
|
||
}
|
||
}
|
||
|
||
// AANGEPAST: Veel eenvoudiger omdat stash items nu permanente URLs hebben
|
||
private func createTempURLForStashItem(_ imgItem: IdentifiableImage) -> URL? {
|
||
// REMOVED: This function is now handled by OptimizedStashItemView
|
||
return imgItem.fileURL
|
||
}
|
||
|
||
// NIEUW: Open image in system app
|
||
private func openImageInSystemApp(_ imgItem: IdentifiableImage, tempURL: URL) {
|
||
// REMOVED: This function is now handled by OptimizedStashItemView
|
||
NSWorkspace.shared.open(tempURL)
|
||
}
|
||
|
||
// NIEUW: Get display name for stash item
|
||
private func displayNameForStashItem(_ imgItem: IdentifiableImage) -> String? {
|
||
// REMOVED: This function is now handled by OptimizedStashItemView
|
||
if let customName = imgItem.customName, !customName.isEmpty {
|
||
return customName
|
||
}
|
||
|
||
if let fileURL = imgItem.fileURL {
|
||
return fileURL.deletingPathExtension().lastPathComponent
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Layout Calculation Functions (gebruiken nu imageStore.images.count etc.)
|
||
private func titleBarHeightIfVisible() -> CGFloat {
|
||
// ULTRA FIX: Titel bar is nu altijd zichtbaar voor consistente layout
|
||
return titleBarHeight
|
||
}
|
||
|
||
private func actualNumberOfColumns() -> Int {
|
||
// 🔥💥⚡ HYPERMODE FLEX GRID SYSTEM! ⚡💥🔥
|
||
let settings = SettingsManager.shared
|
||
let itemCount = max(1, imageStore.images.count) // Minimaal 1 voor consistente layout
|
||
|
||
switch settings.stashGridMode {
|
||
case .fixedColumns:
|
||
// Fixed columns mode: max X columns, auto rows (original system)
|
||
let maxCols = settings.stashMaxColumns
|
||
return min(max(1, itemCount), maxCols)
|
||
|
||
case .fixedRows:
|
||
// Fixed rows mode: max Y rows, auto columns (horizontal growth!)
|
||
let maxRows = settings.stashMaxRows
|
||
return Int(ceil(Double(itemCount) / Double(maxRows)))
|
||
}
|
||
}
|
||
|
||
private func totalNumberOfRowsForAllItems() -> Int {
|
||
// 🔥💥⚡ HYPERMODE FLEX GRID ROWS CALCULATION! ⚡💥🔥
|
||
let settings = SettingsManager.shared
|
||
let itemCount = max(1, imageStore.images.count) // Minimaal 1 voor consistente layout
|
||
|
||
switch settings.stashGridMode {
|
||
case .fixedColumns:
|
||
// Fixed columns mode: calculate rows based on columns
|
||
let numCols = actualNumberOfColumns()
|
||
guard numCols > 0 else { return 1 }
|
||
return Int(ceil(Double(itemCount) / Double(numCols)))
|
||
|
||
case .fixedRows:
|
||
// Fixed rows mode: fixed Y rows, never more!
|
||
let maxRows = settings.stashMaxRows
|
||
return min(maxRows, itemCount) // Never exceed max rows setting
|
||
}
|
||
}
|
||
|
||
private func effectiveWindowWidth() -> CGFloat {
|
||
// ULTRA FIX: Gebruik altijd grid calculations, ook voor empty state
|
||
let numCols = CGFloat(actualNumberOfColumns())
|
||
let thumbSize = self.calculatedThumbnailSize
|
||
let totalImageWidth = numCols * thumbSize
|
||
let totalSpacingWidth = max(0, numCols - 1) * cellSpacing
|
||
return totalImageWidth + totalSpacingWidth + (2 * paddingAroundGrid)
|
||
}
|
||
|
||
private func effectiveWindowHeight() -> CGFloat {
|
||
// ULTRA FIX: Gebruik altijd grid calculations, ook voor empty state
|
||
let numRows = CGFloat(totalNumberOfRowsForAllItems())
|
||
let thumbSize = self.calculatedThumbnailSize
|
||
let totalImageHeight = numRows * thumbSize
|
||
let totalSpacingHeight = max(0, numRows - 1) * cellSpacing
|
||
let dropZoneHeight: CGFloat = 18.0 // AANGEPAST: Slanke dropzone hoogte (tekst ~16px + kleine marge)
|
||
|
||
// AANGEPAST: Gebruik echte padding waarden van imageGridView
|
||
let topPadding = paddingAroundGrid // 5px
|
||
let bottomGridPadding: CGFloat = 0 // 0px - imageGridView heeft geen bottom padding meer
|
||
let dropZoneBottomPadding: CGFloat = 0 // 0px - Geen padding meer op dropzone
|
||
let totalPadding = topPadding + bottomGridPadding + dropZoneBottomPadding // 5 + 0 + 0 = 5px (was 10px)
|
||
|
||
return totalImageHeight + totalSpacingHeight + totalPadding + titleBarHeightIfVisible() + dropZoneHeight
|
||
}
|
||
|
||
// MARK: - Drop Handling
|
||
|
||
// NIEUW: Speciale file-only drop handler die originele namen behoudt
|
||
private func handleFileOnlyDrop(providers: [NSItemProvider]) {
|
||
print("🎯 handleFileOnlyDrop called with \(providers.count) providers - FILENAME PRESERVATION MODE")
|
||
|
||
for provider in providers {
|
||
// Alleen file URLs accepteren
|
||
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
|
||
print("🎯 Found fileURL type in file-only drop")
|
||
|
||
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { (item, error) in
|
||
if let error = error {
|
||
print("❌ Error loading file URL: \(error)")
|
||
return
|
||
}
|
||
|
||
print("🎯 Got item type: \(type(of: item)), value: \(String(describing: item))")
|
||
|
||
var fileURL: URL? = nil
|
||
var filename: String? = nil
|
||
|
||
// VERBETERDE: Verschillende manieren om URL en naam te krijgen (same as handleDrop)
|
||
if let url = item as? URL {
|
||
print("🔄 Direct URL: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
} else if let data = item as? Data {
|
||
print("🔍 Got Data (\(data.count) bytes), trying to decode...")
|
||
|
||
// Probeer als URL data
|
||
if let url = URL(dataRepresentation: data, relativeTo: nil) {
|
||
print("🔄 Decoded URL from data: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
} else if let string = String(data: data, encoding: .utf8) {
|
||
print("🔍 Data as string: '\(string)'")
|
||
|
||
// Probeer string als file path
|
||
let url = URL(string: string) ?? URL(fileURLWithPath: string)
|
||
print("🔄 Decoded URL from string: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
}
|
||
} else if let string = item as? String {
|
||
print("🔍 Got string: '\(string)'")
|
||
let url = URL(string: string) ?? URL(fileURLWithPath: string)
|
||
print("🔄 URL from string: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
} else if let array = item as? [String] {
|
||
print("🔍 Got string array: \(array)")
|
||
if let firstPath = array.first {
|
||
let url = URL(string: firstPath) ?? URL(fileURLWithPath: firstPath)
|
||
print("🔄 URL from array: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
}
|
||
} else if let nsArray = item as? NSArray {
|
||
print("🔍 Got NSArray: \(nsArray)")
|
||
if let firstPath = nsArray.firstObject as? String {
|
||
let url = URL(string: firstPath) ?? URL(fileURLWithPath: firstPath)
|
||
print("🔄 URL from NSArray: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
}
|
||
} else {
|
||
print("❌ Unknown item type for file-only drop: \(type(of: item))")
|
||
return
|
||
}
|
||
|
||
// Als we een file URL hebben, verwerk het bestand
|
||
if let url = fileURL, let name = filename {
|
||
print("✅ Successfully extracted file URL and name: \(name)")
|
||
|
||
// Check of het een image bestand is
|
||
let imageExtensions = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp", "heic"]
|
||
let fileExtension = url.pathExtension.lowercased()
|
||
|
||
guard imageExtensions.contains(fileExtension) else {
|
||
print("⚠️ Not an image file: \(fileExtension)")
|
||
return
|
||
}
|
||
|
||
DispatchQueue.main.async {
|
||
self.copyFileToStash(fileURL: url, preservedName: name, shouldUpdateUI: true)
|
||
}
|
||
} else {
|
||
print("❌ Could not extract valid file URL and name from item")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// UNIFIED: Single method for all stash file operations (replaces copyFileDirectlyToStashAndAdd)
|
||
private func copyFileToStash(fileURL: URL, preservedName: String? = nil, shouldUpdateUI: Bool = false) {
|
||
let finalName = preservedName ?? fileURL.deletingPathExtension().lastPathComponent
|
||
print("🔄 Copying file to stash: \(fileURL.lastPathComponent) as '\(finalName)' (updateUI: \(shouldUpdateUI))")
|
||
|
||
// ULTRA FIX: Check if this is already a stash item being dragged within stash
|
||
let stashDir = imageStore.publicStashDirectory
|
||
if fileURL.path.contains(stashDir.path) {
|
||
print("🔥 ULTRA MODE: Detected INTERNAL stash drag - creating duplicate instead of copy")
|
||
|
||
// Find the existing stash item
|
||
if let existingStashItem = imageStore.images.first(where: { $0.fileURL?.path == fileURL.path }) {
|
||
// Create duplicate with unique name
|
||
let uniqueName = imageStore.generateUniqueCopyName(baseName: finalName)
|
||
|
||
if shouldUpdateUI {
|
||
// Direct add behavior: immediate UI updates for file promise drops
|
||
print("🔥 DEBUG: About to create duplicate with name: '\(uniqueName)'")
|
||
print("🔥 DEBUG: Current image count BEFORE: \(imageStore.images.count)")
|
||
|
||
let oldCount = imageStore.images.count
|
||
imageStore.addImage(existingStashItem.nsImage, fileURL: nil, suggestedName: uniqueName, skipDuplicateCheck: true)
|
||
|
||
print("🔥 DEBUG: Setting currentPreviewName IMMEDIATELY to: '\(uniqueName)'")
|
||
self.currentPreviewName = uniqueName
|
||
|
||
// Wait for the new item to be added, then update hoveredImageID
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||
print("🔥 DEBUG: Async check - image count NOW: \(self.imageStore.images.count)")
|
||
if self.imageStore.images.count > oldCount {
|
||
if let newDuplicate = self.imageStore.images.last {
|
||
print("🔥 MEGA FIX: Updating hoveredImageID to new duplicate: \(newDuplicate.id)")
|
||
print("🔥 DEBUG: New duplicate name: '\(newDuplicate.fileURL?.deletingPathExtension().lastPathComponent ?? "unknown")'")
|
||
self.hoveredImageID = newDuplicate.id
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Handle drop behavior: different UI update pattern for regular drops
|
||
let oldCount = imageStore.images.count
|
||
imageStore.addImage(existingStashItem.nsImage, fileURL: nil, suggestedName: uniqueName, skipDuplicateCheck: true)
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||
if self.imageStore.images.count > oldCount {
|
||
if let newDuplicate = self.imageStore.images.last {
|
||
print("🔥 MEGA FIX: Updating hoveredImageID to new duplicate: \(newDuplicate.id) via handleDrop")
|
||
self.hoveredImageID = newDuplicate.id
|
||
self.currentPreviewName = uniqueName
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
print("🔥 ULTRA MODE: Created internal stash duplicate with name: '\(uniqueName)'")
|
||
return
|
||
}
|
||
}
|
||
|
||
let fileExtension = fileURL.pathExtension
|
||
|
||
// Generate unique filename
|
||
var finalFilename = "\(finalName).\(fileExtension)"
|
||
var counter = 1
|
||
while FileManager.default.fileExists(atPath: stashDir.appendingPathComponent(finalFilename).path) {
|
||
finalFilename = "\(finalName) (\(counter)).\(fileExtension)"
|
||
counter += 1
|
||
if counter > 100 { break }
|
||
}
|
||
|
||
let destinationURL = stashDir.appendingPathComponent(finalFilename)
|
||
|
||
do {
|
||
// Copy the file to stash directory
|
||
try FileManager.default.copyItem(at: fileURL, to: destinationURL)
|
||
print("✅ File copied to stash: \(finalFilename) (preserved name: \(finalName))")
|
||
|
||
// Load image and add to store
|
||
if let nsImage = NSImage(contentsOf: destinationURL) {
|
||
// 🔥 ALWAYS skip duplicate check for external files to allow duplicates from BGR thumbnails
|
||
imageStore.addImage(nsImage, fileURL: destinationURL, suggestedName: finalName, skipDuplicateCheck: true)
|
||
print("✅ Added copied file to stash store: \(finalName) (duplicate check skipped - allows BGR duplicates)")
|
||
}
|
||
} catch {
|
||
print("❌ Failed to copy file to stash: \(error)")
|
||
|
||
// Fallback: use NSImage loading
|
||
if let nsImage = NSImage(contentsOf: fileURL) {
|
||
// 🔥 ALWAYS skip duplicate check for fallback external files too
|
||
imageStore.addImage(nsImage, fileURL: nil, suggestedName: finalName, skipDuplicateCheck: true)
|
||
print("🎯 Fallback: Added image with name '\(finalName)' (duplicate check skipped - allows BGR duplicates)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleDrop(providers: [NSItemProvider]) {
|
||
print("🔍 handleDrop called with \(providers.count) providers")
|
||
|
||
for provider in providers {
|
||
processDropProvider(provider)
|
||
}
|
||
}
|
||
|
||
// MARK: - Private Helper Methods for handleDrop
|
||
|
||
private func processDropProvider(_ provider: NSItemProvider) {
|
||
print("🔍 Provider: \(provider)")
|
||
print("🔍 Registered types: \(provider.registeredTypeIdentifiers)")
|
||
|
||
// Try file URL types first
|
||
if tryProcessFileURLProvider(provider) {
|
||
return
|
||
}
|
||
|
||
// Fallback to NSImage if no file URL found
|
||
processNSImageProvider(provider)
|
||
}
|
||
|
||
private func tryProcessFileURLProvider(_ provider: NSItemProvider) -> Bool {
|
||
// Check for NSFilePromiseProvider metadata first
|
||
if provider.hasItemConformingToTypeIdentifier("com.apple.NSFilePromiseItemMetaData") {
|
||
print("🔍 Found NSFilePromiseProvider metadata")
|
||
processFilePromiseMetadata(provider)
|
||
}
|
||
|
||
// Try different file URL types
|
||
let fileURLTypes = [
|
||
"NSFilenamesPboardType", // Oudere Finder drag
|
||
"public.file-url", // Standaard file URL
|
||
"com.apple.finder.noderef", // Finder node reference
|
||
"com.apple.finder.node", // Finder node
|
||
"public.url", // Algemene URL
|
||
"CorePasteboardFlavorType 0x666C7574" // Oude legacy type
|
||
]
|
||
|
||
for urlType in fileURLTypes {
|
||
if provider.hasItemConformingToTypeIdentifier(urlType) {
|
||
print("🔍 Found file URL with type: \(urlType)")
|
||
processFileURLType(provider, urlType: urlType)
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
private func processFilePromiseMetadata(_ provider: NSItemProvider) {
|
||
provider.loadItem(forTypeIdentifier: "com.apple.NSFilePromiseItemMetaData", options: nil) { (item, error) in
|
||
print("🔍 NSFilePromiseProvider metadata: \(String(describing: item)), error: \(String(describing: error))")
|
||
|
||
if let metadata = item as? [String: Any] {
|
||
print("🔍 Metadata content: \(metadata)")
|
||
if let filename = metadata["filename"] as? String {
|
||
print("🔍 Found filename in metadata: \(filename)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func processFileURLType(_ provider: NSItemProvider, urlType: String) {
|
||
provider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
|
||
print("🔍 File URL callback for \(urlType) - item type: \(type(of: item)), error: \(String(describing: error))")
|
||
|
||
let (fileURL, filename) = self.extractFileURLAndName(from: item)
|
||
|
||
if let url = fileURL, let name = filename {
|
||
print("✅ Successfully extracted file URL and name: \(name)")
|
||
DispatchQueue.main.async {
|
||
self.copyFileToStash(fileURL: url, preservedName: name, shouldUpdateUI: true)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func extractFileURLAndName(from item: Any?) -> (URL?, String?) {
|
||
var fileURL: URL? = nil
|
||
var filename: String? = nil
|
||
|
||
if let url = item as? URL {
|
||
print("🔄 Direct URL: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
} else if let data = item as? Data {
|
||
print("🔍 Got Data (\(data.count) bytes), trying to decode...")
|
||
(fileURL, filename) = extractFromData(data)
|
||
} else if let string = item as? String {
|
||
print("🔍 Got string: '\(string)'")
|
||
let url = URL(string: string) ?? URL(fileURLWithPath: string)
|
||
print("🔄 URL from string: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
} else if let array = item as? [String] {
|
||
print("🔍 Got string array: \(array)")
|
||
if let firstPath = array.first {
|
||
let url = URL(string: firstPath) ?? URL(fileURLWithPath: firstPath)
|
||
print("🔄 URL from array: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
}
|
||
} else if let nsArray = item as? NSArray {
|
||
print("🔍 Got NSArray: \(nsArray)")
|
||
if let firstPath = nsArray.firstObject as? String {
|
||
let url = URL(string: firstPath) ?? URL(fileURLWithPath: firstPath)
|
||
print("🔄 URL from NSArray: \(url.lastPathComponent)")
|
||
fileURL = url
|
||
filename = url.deletingPathExtension().lastPathComponent
|
||
}
|
||
}
|
||
|
||
return (fileURL, filename)
|
||
}
|
||
|
||
private func extractFromData(_ data: Data) -> (URL?, String?) {
|
||
// Try as URL data
|
||
if let url = URL(dataRepresentation: data, relativeTo: nil) {
|
||
print("🔄 Decoded URL from data: \(url.lastPathComponent)")
|
||
return (url, url.deletingPathExtension().lastPathComponent)
|
||
} else if let string = String(data: data, encoding: .utf8) {
|
||
print("🔍 Data as string: '\(string)'")
|
||
let url = URL(string: string) ?? URL(fileURLWithPath: string)
|
||
print("🔄 Decoded URL from string: \(url.lastPathComponent)")
|
||
return (url, url.deletingPathExtension().lastPathComponent)
|
||
}
|
||
|
||
return (nil, nil)
|
||
}
|
||
|
||
private func processNSImageProvider(_ provider: NSItemProvider) {
|
||
guard provider.canLoadObject(ofClass: NSImage.self) else {
|
||
print("Kan object niet laden als NSImage.")
|
||
return
|
||
}
|
||
|
||
print("🔍 Can load NSImage, checking for better name sources...")
|
||
|
||
// Try to extract name from various sources
|
||
let nameForStore = extractNameFromProvider(provider)
|
||
|
||
_ = provider.loadObject(ofClass: NSImage.self) { image, error in
|
||
if let nsImage = image as? NSImage {
|
||
print("🔍 Loaded NSImage successfully")
|
||
print("🔍 About to add to store with name: '\(nameForStore ?? "nil")'")
|
||
|
||
DispatchQueue.main.async {
|
||
// Skip duplicate check for external NSImage drops
|
||
self.imageStore.addImage(nsImage, fileURL: nil, suggestedName: nameForStore, skipDuplicateCheck: true)
|
||
}
|
||
} else {
|
||
if let error = error {
|
||
print("Fout bij het laden van afbeelding: \(error.localizedDescription)")
|
||
} else {
|
||
print("Kon afbeelding niet laden, onbekende fout.")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func extractNameFromProvider(_ provider: NSItemProvider) -> String? {
|
||
// 1. Provider suggested name
|
||
if let suggestedNameFromProvider = provider.suggestedName, !suggestedNameFromProvider.isEmpty {
|
||
let name = (suggestedNameFromProvider as NSString).deletingPathExtension
|
||
print("🔍 Provider suggested name: \(suggestedNameFromProvider) -> \(name)")
|
||
return name
|
||
}
|
||
|
||
// 2. Try via pasteboard types
|
||
for typeId in provider.registeredTypeIdentifiers {
|
||
print("🔍 Examining type identifier: \(typeId)")
|
||
|
||
// Sometimes name is in the type identifier
|
||
if typeId.contains(".") && !typeId.hasPrefix("public.") && !typeId.hasPrefix("com.apple") {
|
||
if let extractedName = typeId.components(separatedBy: ".").last,
|
||
extractedName.count > 3 && extractedName.count < 50 {
|
||
print("🔍 Extracted name from type identifier: \(extractedName)")
|
||
return extractedName
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// NIEUW: Helper functie om file URL te verwerken
|
||
private func processFileURL(_ url: URL) {
|
||
// Check of het een image bestand is
|
||
let imageExtensions = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp", "heic"]
|
||
let fileExtension = url.pathExtension.lowercased()
|
||
|
||
if imageExtensions.contains(fileExtension) {
|
||
DispatchQueue.main.async {
|
||
let filename = url.deletingPathExtension().lastPathComponent
|
||
self.copyFileToStash(fileURL: url, preservedName: filename)
|
||
}
|
||
} else {
|
||
print("⚠️ Not an image file: \(fileExtension)")
|
||
}
|
||
}
|
||
|
||
// AANGEPAST: Kopieer origineel bestand naar stash directory met preserved name
|
||
|
||
|
||
// 🔥 NIEUW: Drop Zone fade timer functie
|
||
private func startDropZoneFadeTimer() {
|
||
print("⏰ Drop Zone: startDropZoneFadeTimer() CALLED! dropZoneOpacity: \(dropZoneOpacity)")
|
||
|
||
// Reset als er al een timer loopt
|
||
dropZoneFadeTimer?.invalidate()
|
||
|
||
// Na 3 seconden starten met uitfaden over 10 seconden
|
||
dropZoneFadeTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
|
||
print("🌅 Drop Zone: Starting 10-second fade out...")
|
||
|
||
// Gebruik SwiftUI animatie voor smooth fade over 10 seconden
|
||
withAnimation(.linear(duration: 10.0)) {
|
||
dropZoneOpacity = 0.0
|
||
}
|
||
|
||
// Log completion
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
|
||
print("🌙 Drop Zone: Fade out completed (fully transparent)")
|
||
}
|
||
}
|
||
|
||
print("⏰ Drop Zone: 3-second delay timer started, will fade over 10 seconds")
|
||
}
|
||
|
||
// 🎯 NIEUW: Test shake animation functie - GEFIXT MET CAKeyframeAnimation!
|
||
private func testShakeAnimation() {
|
||
guard let window = hostingWindow else {
|
||
print("🚨 Shake test: No hosting window available!")
|
||
return
|
||
}
|
||
|
||
print("🔥 MEGA SHAKE: Starting CAKeyframeAnimation shake!")
|
||
|
||
// PROVEN WORKING TECHNIQUE from Eric Dolecki blog
|
||
let numberOfShakes = 4
|
||
let durationOfShake = 0.3
|
||
let vigourOfShake: CGFloat = 0.03 // Percentage of window width
|
||
let frame = window.frame
|
||
|
||
print("🔥 MEGA SHAKE: Parameters - shakes: \(numberOfShakes), duration: \(durationOfShake)s, vigour: \(vigourOfShake)")
|
||
print("🔥 MEGA SHAKE: Window frame: \(frame)")
|
||
|
||
// Create CAKeyframeAnimation
|
||
let shakeAnimation = CAKeyframeAnimation()
|
||
|
||
// Create shake path
|
||
let shakePath = CGMutablePath()
|
||
shakePath.move(to: CGPoint(x: frame.minX, y: frame.minY))
|
||
|
||
for _ in 0..<numberOfShakes {
|
||
// Move left
|
||
shakePath.addLine(to: CGPoint(
|
||
x: frame.minX - frame.size.width * vigourOfShake,
|
||
y: frame.minY
|
||
))
|
||
// Move right
|
||
shakePath.addLine(to: CGPoint(
|
||
x: frame.minX + frame.size.width * vigourOfShake,
|
||
y: frame.minY
|
||
))
|
||
}
|
||
|
||
shakePath.closeSubpath()
|
||
shakeAnimation.path = shakePath
|
||
shakeAnimation.duration = durationOfShake
|
||
|
||
// CRITICAL: Set animations dictionary and trigger with animator
|
||
window.animations = ["frameOrigin": shakeAnimation]
|
||
window.animator().setFrameOrigin(window.frame.origin)
|
||
|
||
print("🔥 MEGA SHAKE: CAKeyframeAnimation applied to window!")
|
||
print("🔥 MEGA SHAKE: Path contains \(numberOfShakes * 2) shake points")
|
||
|
||
// Debug completion after animation duration
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + durationOfShake + 0.1) {
|
||
print("🔥 MEGA SHAKE: Animation should be completed!")
|
||
}
|
||
}
|
||
|
||
// MARK: - Preview Window Logic (blijft grotendeels hetzelfde)
|
||
private func handlePreviewWindowState(shouldShow: Bool) {
|
||
print("🖼️ IntegratedGalleryView: handlePreviewWindowState called - shouldShow: \(shouldShow), imageForPreview isNil: \(self.imageForPreview == nil), isPreviewStable: \(isPreviewStable)")
|
||
|
||
// NIEUW: Niet handelen als preview niet stabiel is
|
||
guard isPreviewStable || !shouldShow else {
|
||
print("🖼️ IntegratedGalleryView: Skipping preview update - not stable yet")
|
||
return
|
||
}
|
||
|
||
guard let stashWindow = self.hostingWindow else {
|
||
print("🖼️ IntegratedGalleryView: ERROR - hostingWindow is nil. Cannot show/hide preview.")
|
||
if !shouldShow {
|
||
stopPreviewNameRefreshTimer() // ⏰ Stop timer
|
||
closePreviewWindow()
|
||
}
|
||
return
|
||
}
|
||
|
||
if shouldShow, let imageToDisplayInPreview = self.imageForPreview {
|
||
print("🖼️ IntegratedGalleryView: handlePreviewWindowState - Condition met to show/update preview.")
|
||
|
||
// ⏰ START TIMER FORCE REFRESH!
|
||
startPreviewNameRefreshTimer()
|
||
|
||
// ULTRA FIX: Haal de REALTIJDS naam op van het huidige hovered item
|
||
let currentImageName: String
|
||
if let hoveredID = hoveredImageID,
|
||
let hoveredItem = imageStore.images.first(where: { $0.id == hoveredID }) {
|
||
// MEGA FIX: Use actual filename instead of customName to show unique names
|
||
currentImageName = hoveredItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown"
|
||
currentPreviewName = currentImageName // Update state variable too
|
||
print("🔍 ULTRA DEBUG: handlePreviewWindowState - hoveredID: \(hoveredID), found name: '\(currentImageName)' from item: \(hoveredItem.fileURL?.lastPathComponent ?? "nil")")
|
||
} else {
|
||
currentImageName = "Unknown"
|
||
currentPreviewName = "Unknown"
|
||
print("🔍 ULTRA DEBUG: handlePreviewWindowState - NO hoveredID or item found, using 'Unknown'")
|
||
}
|
||
|
||
guard stashWindow.screen != nil else {
|
||
print("🖼️ IntegratedGalleryView: ERROR - Stash window has no screen. Cannot determine target screen for preview.")
|
||
if !shouldShow {
|
||
stopPreviewNameRefreshTimer() // ⏰ Stop timer
|
||
closePreviewWindow()
|
||
}
|
||
return
|
||
}
|
||
|
||
// FIXED: Vereenvoudigde positionering zonder hoveredItemFrameInWindow
|
||
let stashWindowFrame = stashWindow.frame
|
||
let previewScreen = stashWindow.screen ?? NSScreen.main ?? NSScreen.screens.first! // Scherm van het stash window
|
||
|
||
// 🔥💎 MEGA DYNAMIC PREVIEW SIZE BASED ON SETTINGS! 💎🔥
|
||
let originalSize = imageToDisplayInPreview.size
|
||
let setting = SettingsManager.shared.stashPreviewSize
|
||
|
||
// 🔥 MEGA INTELLIGENTE SIZE CALCULATION! 🔥
|
||
// Use fixed target sizes based on setting, with aspect ratio preservation
|
||
let targetSizes: [StashPreviewSize: CGSize] = [
|
||
.xSmall: CGSize(width: 150, height: 112), // ~25% of 600x450
|
||
.small: CGSize(width: 240, height: 180), // ~40% of 600x450
|
||
.medium: CGSize(width: 360, height: 270), // ~60% of 600x450
|
||
.large: CGSize(width: 480, height: 360), // ~80% of 600x450
|
||
.xLarge: CGSize(width: 600, height: 450) // 100% baseline size
|
||
]
|
||
|
||
let targetSize = targetSizes[setting] ?? targetSizes[.medium]!
|
||
|
||
// Calculate scale to fit within target size while preserving aspect ratio
|
||
let widthScale = targetSize.width / originalSize.width
|
||
let heightScale = targetSize.height / originalSize.height
|
||
let scale = min(widthScale, heightScale) // Use smaller scale to fit within bounds
|
||
|
||
var previewWidth = originalSize.width * scale
|
||
var previewHeight = originalSize.height * scale
|
||
|
||
// 🔥 ABSOLUTE MIN/MAX LIMIETEN! 🔥
|
||
previewWidth = max(80, min(previewWidth, 1000)) // Absolute min 80px, max 1000px
|
||
previewHeight = max(60, min(previewHeight, 750)) // Absolute min 60px, max 750px
|
||
|
||
print("🔥💎 MEGA PREVIEW SIZE: Original=\(originalSize), Setting=\(setting.displayName), Target=\(targetSize), Scale=\(scale), Final=\(previewWidth)x\(previewHeight)")
|
||
|
||
let spacing: CGFloat = 10
|
||
var previewX: CGFloat
|
||
var previewY: CGFloat
|
||
|
||
// 🔥💥⚡ MEGA INTELLIGENT 2x3 GRID POSITIONING SYSTEM! ⚡💥🔥
|
||
// Deel het scherm op in 2x3 grid voor logischere preview richting!
|
||
let screenBounds = previewScreen.visibleFrame
|
||
let screenWidth = screenBounds.width
|
||
let screenHeight = screenBounds.height
|
||
|
||
// Calculate grid boundaries - SIMPLE 2 COLUMN SYSTEM!
|
||
let middleX = screenBounds.minX + (screenWidth / 2) // 🔥 SIMPLE 50% SPLIT!
|
||
let bottomThird = screenBounds.minY + (screenHeight / 3)
|
||
let topThird = screenBounds.minY + (2 * screenHeight / 3)
|
||
|
||
// Detect stash position in 2x3 grid
|
||
let stashCenterX = stashWindowFrame.midX
|
||
let stashCenterY = stashWindowFrame.midY
|
||
|
||
// Determine grid column (1=left, 2=right) - SIMPLE!
|
||
let gridColumn: Int
|
||
if stashCenterX < middleX {
|
||
gridColumn = 1 // Left half
|
||
} else {
|
||
gridColumn = 2 // Right half
|
||
}
|
||
|
||
// Determine grid row (1=bottom, 2=middle, 3=top)
|
||
let gridRow: Int
|
||
if stashCenterY < bottomThird {
|
||
gridRow = 1 // Bottom third
|
||
} else if stashCenterY < topThird {
|
||
gridRow = 2 // Middle third
|
||
} else {
|
||
gridRow = 3 // Top third
|
||
}
|
||
|
||
print("🔥 MEGA GRID: Stash is in column \(gridColumn), row \(gridRow) of 2x3 grid")
|
||
print("🔥 MEGA GRID: Screen bounds: \(screenBounds)")
|
||
print("🔥 MEGA GRID: Stash center: (\(stashCenterX), \(stashCenterY))")
|
||
print("🔥 MEGA GRID: Grid boundaries - Middle: \(middleX), Bottom: \(bottomThird), Top: \(topThird)")
|
||
print("🔥 MEGA GRID: Stash X percentage: \(Int((stashCenterX - screenBounds.minX) / screenWidth * 100))%")
|
||
|
||
// 🌋💥⚡ HYPERMODE INTELLIGENT PREVIEW POSITIONING! ⚡💥🌋
|
||
// Grid mode bepaalt preview richting!
|
||
let currentGridMode = SettingsManager.shared.stashGridMode
|
||
print("🌋 VULCANO DEBUG: Grid mode = \(currentGridMode), positioning preview accordingly")
|
||
|
||
if currentGridMode == .fixedRows {
|
||
// 🔝🔽 FIXED ROWS = HORIZONTALE STRIP → PREVIEW BOVEN/ONDER! 🔽🔝
|
||
print("🌋 VULCANO: Fixed Rows mode - positioning preview ABOVE/BELOW stash")
|
||
previewX = stashWindowFrame.midX - previewWidth / 2
|
||
|
||
// 🔥 MEGA GRID FORCED POSITIONING FOR ROWS! 🔥
|
||
if gridRow == 3 {
|
||
// Top third: ALWAYS position preview BELOW
|
||
previewY = stashWindowFrame.minY - (spacing + 25) - previewHeight
|
||
print("🔥 MEGA GRID: Row 3 (TOP) - FORCING preview BELOW!")
|
||
} else if gridRow == 1 {
|
||
// Bottom third: ALWAYS position preview ABOVE
|
||
previewY = stashWindowFrame.maxY + spacing
|
||
print("🔥 MEGA GRID: Row 1 (BOTTOM) - FORCING preview ABOVE!")
|
||
} else {
|
||
// Middle third: Use smart positioning (current logic)
|
||
print("🔥 MEGA GRID: Row 2 (MIDDLE) - Using smart positioning")
|
||
// Probeer boven het stash window
|
||
if stashWindowFrame.maxY + spacing + previewHeight <= previewScreen.visibleFrame.maxY {
|
||
previewY = stashWindowFrame.maxY + spacing
|
||
print("🔝 VULCANO: Positioning preview ABOVE stash window")
|
||
}
|
||
// Probeer onder het stash window
|
||
else if stashWindowFrame.minY - (spacing + 25) - previewHeight >= previewScreen.visibleFrame.minY {
|
||
previewY = stashWindowFrame.minY - (spacing + 25) - previewHeight
|
||
print("🔽 VULCANO: Positioning preview BELOW stash window with EXTRA spacing!")
|
||
}
|
||
// Fallback: rechtsboven op het scherm
|
||
else {
|
||
previewY = previewScreen.visibleFrame.maxY - previewHeight - 20
|
||
print("🌋 VULCANO: No space above/below - fallback to top of screen")
|
||
}
|
||
}
|
||
} else {
|
||
// ◀️▶️ FIXED COLUMNS = VERTICALE STRIP → PREVIEW LINKS/RECHTS! ▶️◀️
|
||
print("🌋 VULCANO: Fixed Columns mode - positioning preview LEFT/RIGHT of stash")
|
||
previewY = stashWindowFrame.midY - previewHeight / 2
|
||
|
||
// 🔥 MEGA GRID FORCED POSITIONING FOR COLUMNS! 🔥
|
||
if gridColumn == 1 {
|
||
// Left half: ALWAYS position preview RIGHT
|
||
previewX = stashWindowFrame.maxX + spacing
|
||
print("🔥 MEGA GRID: Column 1 (LEFT HALF) - FORCING preview RIGHT!")
|
||
} else {
|
||
// Right half: ALWAYS position preview LEFT
|
||
previewX = stashWindowFrame.minX - (spacing + 15) - previewWidth
|
||
print("🔥 MEGA GRID: Column 2 (RIGHT HALF) - FORCING preview LEFT!")
|
||
}
|
||
}
|
||
|
||
let finalPreviewOrigin = NSPoint(
|
||
x: max(previewScreen.visibleFrame.minX, min(previewX, previewScreen.visibleFrame.maxX - previewWidth)),
|
||
y: max(previewScreen.visibleFrame.minY, min(previewY, previewScreen.visibleFrame.maxY - previewHeight))
|
||
)
|
||
print("🌋 VULCANO: Final preview position: \(finalPreviewOrigin)")
|
||
|
||
// --- VERBETERDE logica voor het creëren/updaten van het venster ---
|
||
if previewWindowController == nil {
|
||
print("🖼️ IntegratedGalleryView: previewWindowController is nil. Creating new preview window.")
|
||
createNewPreviewWindow(imageToDisplayInPreview, at: finalPreviewOrigin, size: NSSize(width: previewWidth, height: previewHeight), screen: previewScreen)
|
||
} else {
|
||
print("🖼️ IntegratedGalleryView: previewWindowController exists. Updating existing window.")
|
||
updateExistingPreviewWindow(imageToDisplayInPreview, at: finalPreviewOrigin, size: NSSize(width: previewWidth, height: previewHeight))
|
||
}
|
||
} else {
|
||
print("🖼️ IntegratedGalleryView: handlePreviewWindowState - Condition NOT met to show preview OR shouldHide. Closing.")
|
||
|
||
// ⏰ STOP TIMER FORCE REFRESH!
|
||
stopPreviewNameRefreshTimer()
|
||
|
||
// NIEUW: Minder aggressive sluiten
|
||
if let wc = self.previewWindowController, let pWindow = wc.window, pWindow.isVisible {
|
||
NSAnimationContext.runAnimationGroup({ context in
|
||
context.duration = 0.15 // Kortere fade out
|
||
pWindow.animator().alphaValue = 0
|
||
}, completionHandler: {
|
||
self.closePreviewWindow()
|
||
})
|
||
} else {
|
||
closePreviewWindow()
|
||
}
|
||
}
|
||
}
|
||
|
||
// NIEUW: Separate functie voor nieuwe preview window
|
||
private func createNewPreviewWindow(_ image: NSImage, at origin: NSPoint, size: NSSize, screen: NSScreen) {
|
||
// 🔥💎 MEGA TITANIUM GLASS EFFECT PREVIEW! 💎🔥
|
||
let previewContentSwiftUIView = VStack(spacing: 4) {
|
||
Image(nsImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.cornerRadius(8)
|
||
.clipped()
|
||
.shadow(radius: 3, y: 2)
|
||
|
||
// 🚀💥 TURBO INSTANT NAAM LABEL! 💥🚀
|
||
Text(currentPreviewName)
|
||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||
.foregroundColor(Color.adaptivePrimaryText) // 🔥 ADAPTIVE text voor light/dark mode
|
||
.opacity(0.9) // 🔥 Hoge opacity voor zichtbaarheid
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
.padding(.horizontal, 8)
|
||
.onAppear {
|
||
print("🔥 ULTRA DEBUG: Preview text appeared with INSTANT name: '\(currentPreviewName)'")
|
||
}
|
||
}
|
||
.padding(8) // 🔥 EXTRA PADDING voor schaduw ruimte
|
||
.background(
|
||
ZStack {
|
||
// 🔥💎 MEGA GLASS EFFECT ZOALS STASH! 💎🔥
|
||
VisualEffectBackground(material: .hudWindow, blending: .behindWindow, alpha: 0.95)
|
||
.cornerRadius(12)
|
||
|
||
// 🔥 Extra glas laag voor diepte
|
||
VisualEffectBackground(material: .popover, blending: .withinWindow, alpha: 0.3)
|
||
.cornerRadius(12)
|
||
}
|
||
)
|
||
.cornerRadius(12)
|
||
.shadow(color: .black.opacity(0.4), radius: 8, x: 0, y: 3) // 🔥 Zachte schaduw die past in window
|
||
.padding(16) // 🔥 CRITICAL: Extra padding voor schaduw ruimte
|
||
|
||
let hostingController = NSHostingController(rootView: previewContentSwiftUIView)
|
||
let previewWindow = NSWindow(contentViewController: hostingController)
|
||
|
||
// 🔥 MEGA FIX: Extra grote window size voor schaduw ruimte (shadow radius 8 + padding 16 = 24px extra aan alle kanten)
|
||
let shadowPadding: CGFloat = 32 // Extra ruimte voor schaduw en padding
|
||
let previewSize = CGSize(
|
||
width: size.width + shadowPadding + 20, // Original + shadow space + name space
|
||
height: size.height + shadowPadding + 40 // Original + shadow space + name space
|
||
)
|
||
let adjustedOrigin = CGPoint(
|
||
x: origin.x - shadowPadding/2, // Compenseer voor shadow padding
|
||
y: origin.y - shadowPadding/2 - 10 // Compenseer voor shadow padding + 10px hoger
|
||
)
|
||
|
||
previewWindow.setFrame(NSRect(origin: adjustedOrigin, size: previewSize), display: false)
|
||
previewWindow.styleMask = [.borderless] // 🔥 BLIJFT borderless
|
||
previewWindow.backgroundColor = NSColor.clear // 🔥 Volledig transparant
|
||
previewWindow.isOpaque = false // 🔥 Niet opaque
|
||
previewWindow.hasShadow = false // 🔥 NATIVE window shadow UIT - we gebruiken SwiftUI shadow
|
||
previewWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
|
||
previewWindow.ignoresMouseEvents = true
|
||
|
||
let windowController = NSWindowController(window: previewWindow)
|
||
self.previewWindowController = windowController
|
||
|
||
previewWindow.orderFront(nil)
|
||
previewWindow.alphaValue = 1.0
|
||
|
||
print("🖼️ IntegratedGalleryView: Positioned NEW SHADOW-FRIENDLY stash hover preview at (\(adjustedOrigin.x), \(adjustedOrigin.y)) with size \(previewSize)")
|
||
print("🖼️ IntegratedGalleryView: Showing new BORDERLESS preview window with embedded shadows")
|
||
}
|
||
|
||
// ULTRA HELPER FUNCTIE: Real-time naam lookup
|
||
private func getCurrentHoveredImageName() -> String {
|
||
if let hoveredID = hoveredImageID,
|
||
let hoveredItem = imageStore.images.first(where: { $0.id == hoveredID }) {
|
||
let name = hoveredItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown"
|
||
print("🔥 ULTRA DEBUG: getCurrentHoveredImageName() -> '\(name)' for ID \(hoveredID)")
|
||
return name
|
||
}
|
||
print("🔥 ULTRA DEBUG: getCurrentHoveredImageName() -> 'Unknown' (no hovered ID)")
|
||
return "Unknown"
|
||
}
|
||
|
||
// NIEUW: Separate functie voor update van bestaande preview window
|
||
private func updateExistingPreviewWindow(_ image: NSImage, at origin: NSPoint, size: NSSize) {
|
||
guard let wc = self.previewWindowController, let pWindow = wc.window else {
|
||
print("🖼️ IntegratedGalleryView: ERROR - previewWindowController or its window is nil in update path.")
|
||
return
|
||
}
|
||
|
||
print("🔥 ULTRA DEBUG: updateExistingPreviewWindow - INSTANT name: '\(currentPreviewName)'")
|
||
|
||
// 🔥💎 MEGA TITANIUM GLASS EFFECT UPDATE! 💎🔥
|
||
let updatedPreviewContentSwiftUIView = VStack(spacing: 4) {
|
||
Image(nsImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.cornerRadius(8)
|
||
.clipped()
|
||
.shadow(radius: 3, y: 2)
|
||
|
||
// 🚀💥 TURBO INSTANT NAAM LABEL! 💥🚀
|
||
Text(currentPreviewName)
|
||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||
.foregroundColor(Color.adaptivePrimaryText) // 🔥 ADAPTIVE text voor light/dark mode
|
||
.opacity(0.9) // 🔥 Hoge opacity voor zichtbaarheid
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
.padding(.horizontal, 8)
|
||
.onAppear {
|
||
print("🔥 ULTRA DEBUG: Updated preview text with INSTANT name: '\(currentPreviewName)'")
|
||
}
|
||
}
|
||
.padding(8) // 🔥 EXTRA PADDING voor schaduw ruimte
|
||
.background(
|
||
ZStack {
|
||
// 🔥💎 MEGA GLASS EFFECT ZOALS STASH! 💎🔥
|
||
VisualEffectBackground(material: .hudWindow, blending: .behindWindow, alpha: 0.95)
|
||
.cornerRadius(12)
|
||
|
||
// 🔥 Extra glas laag voor diepte
|
||
VisualEffectBackground(material: .popover, blending: .withinWindow, alpha: 0.3)
|
||
.cornerRadius(12)
|
||
}
|
||
)
|
||
.cornerRadius(12)
|
||
.shadow(color: .black.opacity(0.4), radius: 8, x: 0, y: 3) // 🔥 Zachte schaduw die past in window
|
||
.padding(16) // 🔥 CRITICAL: Extra padding voor schaduw ruimte
|
||
|
||
let updatedHostingController = NSHostingController(rootView: updatedPreviewContentSwiftUIView)
|
||
pWindow.contentViewController = updatedHostingController
|
||
|
||
// 🔥💎 MEGA DYNAMIC SIZE UPDATE! 💎🔥
|
||
let originalSize = image.size
|
||
let setting = SettingsManager.shared.stashPreviewSize
|
||
|
||
// 🔥 MEGA INTELLIGENTE SIZE CALCULATION! 🔥
|
||
let targetSizes: [StashPreviewSize: CGSize] = [
|
||
.xSmall: CGSize(width: 150, height: 112),
|
||
.small: CGSize(width: 240, height: 180),
|
||
.medium: CGSize(width: 360, height: 270),
|
||
.large: CGSize(width: 480, height: 360),
|
||
.xLarge: CGSize(width: 600, height: 450)
|
||
]
|
||
|
||
let targetSize = targetSizes[setting] ?? targetSizes[.medium]!
|
||
let widthScale = targetSize.width / originalSize.width
|
||
let heightScale = targetSize.height / originalSize.height
|
||
let scale = min(widthScale, heightScale)
|
||
|
||
var dynamicWidth = originalSize.width * scale
|
||
var dynamicHeight = originalSize.height * scale
|
||
|
||
// 🔥 ABSOLUTE MIN/MAX LIMIETEN! 🔥
|
||
dynamicWidth = max(80, min(dynamicWidth, 1000))
|
||
dynamicHeight = max(60, min(dynamicHeight, 750))
|
||
|
||
// 🔥 MEGA FIX: Update position and size with shadow-friendly dimensions
|
||
let shadowPadding: CGFloat = 32 // Extra ruimte voor schaduw en padding
|
||
let previewSize = CGSize(
|
||
width: dynamicWidth + shadowPadding + 20, // Original + shadow space + name space
|
||
height: dynamicHeight + shadowPadding + 40 // Original + shadow space + name space
|
||
)
|
||
let adjustedOrigin = CGPoint(
|
||
x: origin.x - shadowPadding/2, // Compenseer voor shadow padding
|
||
y: origin.y - shadowPadding/2 - 10 // Compenseer voor shadow padding + 10px hoger
|
||
)
|
||
pWindow.setFrame(NSRect(origin: adjustedOrigin, size: previewSize), display: true)
|
||
|
||
print("🖼️ IntegratedGalleryView: Positioned UPDATED SHADOW-FRIENDLY stash hover preview at (\(adjustedOrigin.x), \(adjustedOrigin.y)) with dynamic size \(dynamicWidth)x\(dynamicHeight)")
|
||
}
|
||
|
||
private func closePreviewWindow() {
|
||
stopPreviewNameRefreshTimer() // ⏰ Stop timer when closing
|
||
previewWindowController?.window?.orderOut(nil)
|
||
previewWindowController?.close()
|
||
previewWindowController = nil
|
||
}
|
||
|
||
// ⏰ TIMER FORCE REFRESH FUNCTIONS
|
||
private func startPreviewNameRefreshTimer() {
|
||
// Stop existing timer first
|
||
stopPreviewNameRefreshTimer()
|
||
|
||
print("⏰ TIMER: Starting 0.2s refresh timer for preview name")
|
||
previewNameRefreshTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
|
||
DispatchQueue.main.async { [self] in
|
||
self.forceRefreshPreviewName()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopPreviewNameRefreshTimer() {
|
||
if previewNameRefreshTimer != nil {
|
||
print("⏰ TIMER: Stopping refresh timer")
|
||
previewNameRefreshTimer?.invalidate()
|
||
previewNameRefreshTimer = nil
|
||
}
|
||
}
|
||
|
||
private func forceRefreshPreviewName() {
|
||
guard showPreview else {
|
||
print("⏰ TIMER DEBUG: Not showing preview, skipping refresh")
|
||
return
|
||
}
|
||
|
||
print("⏰ TIMER DEBUG: forceRefreshPreviewName called")
|
||
print("⏰ TIMER DEBUG: hoveredImageID: \(hoveredImageID?.uuidString ?? "nil")")
|
||
print("⏰ TIMER DEBUG: isTargeted: \(isTargeted)")
|
||
print("⏰ TIMER DEBUG: imageStore.images.count: \(imageStore.images.count)")
|
||
|
||
// 🔥 MEGA ULTRA FIX: Check if we're hovering over the dropzone
|
||
// If the hoveredImageID is nil but we're showing preview, it means we're hovering over dropzone
|
||
// In that case, show the LAST item (newest duplicate)
|
||
let itemToShow: IdentifiableImage?
|
||
|
||
if let hoveredID = hoveredImageID {
|
||
// Normal case: hovering over a specific thumbnail
|
||
itemToShow = imageStore.images.first(where: { $0.id == hoveredID })
|
||
print("⏰ TIMER: Using hovered item with ID: \(hoveredID)")
|
||
} else if isTargeted && !imageStore.images.isEmpty {
|
||
// 🔥 SPECIAL CASE: Hovering over dropzone - show LAST item (newest)
|
||
itemToShow = imageStore.images.last
|
||
print("⏰ TIMER: DROPZONE HOVER DETECTED - Using LAST item (newest duplicate)")
|
||
} else {
|
||
itemToShow = nil
|
||
print("⏰ TIMER DEBUG: No item to show")
|
||
}
|
||
|
||
// Get current item name
|
||
if let item = itemToShow {
|
||
let newName = item.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown"
|
||
print("⏰ TIMER DEBUG: Item found, name: '\(newName)', current preview name: '\(currentPreviewName)'")
|
||
|
||
// Only update if name actually changed to avoid unnecessary updates
|
||
if newName != currentPreviewName {
|
||
currentPreviewName = newName
|
||
print("⏰ TIMER: Force refreshed preview name to '\(newName)'")
|
||
|
||
// Force update the preview window if it exists
|
||
if previewWindowController != nil, let image = imageForPreview {
|
||
print("⏰ TIMER DEBUG: Updating preview window with new name")
|
||
let stashWindowFrame = hostingWindow?.frame ?? NSRect.zero
|
||
let previewScreen = hostingWindow?.screen ?? NSScreen.main ?? NSScreen.screens.first!
|
||
|
||
// 🔥💎 MEGA DYNAMIC SIZE FOR TIMER UPDATE TOO! 💎🔥
|
||
let originalSize = image.size
|
||
let setting = SettingsManager.shared.stashPreviewSize
|
||
|
||
// 🔥 MEGA INTELLIGENTE SIZE CALCULATION! 🔥
|
||
let targetSizes: [StashPreviewSize: CGSize] = [
|
||
.xSmall: CGSize(width: 150, height: 112),
|
||
.small: CGSize(width: 240, height: 180),
|
||
.medium: CGSize(width: 360, height: 270),
|
||
.large: CGSize(width: 480, height: 360),
|
||
.xLarge: CGSize(width: 600, height: 450)
|
||
]
|
||
|
||
let targetSize = targetSizes[setting] ?? targetSizes[.medium]!
|
||
let widthScale = targetSize.width / originalSize.width
|
||
let heightScale = targetSize.height / originalSize.height
|
||
let scale = min(widthScale, heightScale)
|
||
|
||
var previewWidth = originalSize.width * scale
|
||
var previewHeight = originalSize.height * scale
|
||
|
||
// 🔥 ABSOLUTE MIN/MAX LIMIETEN! 🔥
|
||
previewWidth = max(80, min(previewWidth, 1000))
|
||
previewHeight = max(60, min(previewHeight, 750))
|
||
|
||
let spacing: CGFloat = 10
|
||
var previewX: CGFloat
|
||
let previewY = stashWindowFrame.midY - previewHeight / 2
|
||
|
||
if stashWindowFrame.maxX + spacing + previewWidth <= previewScreen.visibleFrame.maxX {
|
||
previewX = stashWindowFrame.maxX + spacing
|
||
} else if stashWindowFrame.minX - (spacing + 15) - previewWidth >= previewScreen.visibleFrame.minX {
|
||
previewX = stashWindowFrame.minX - (spacing + 15) - previewWidth
|
||
} else {
|
||
previewX = previewScreen.visibleFrame.maxX - previewWidth - 20
|
||
}
|
||
|
||
let finalPreviewOrigin = NSPoint(x: previewX, y: max(previewScreen.visibleFrame.minY, min(previewY, previewScreen.visibleFrame.maxY - previewHeight)))
|
||
|
||
updateExistingPreviewWindow(image, at: finalPreviewOrigin, size: NSSize(width: previewWidth, height: previewHeight))
|
||
} else {
|
||
print("⏰ TIMER DEBUG: No preview window controller to update")
|
||
}
|
||
} else {
|
||
print("⏰ TIMER DEBUG: Name unchanged, skipping update")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Save dropped thumbnail
|
||
private func saveDroppedURL(_ sourceURL: URL, fileName: String) {
|
||
guard let destFolder = SettingsManager.shared.screenshotFolder, !destFolder.isEmpty else {
|
||
print("❌ Save failed: default folder not set")
|
||
return
|
||
}
|
||
let destURL = URL(fileURLWithPath: destFolder).appendingPathComponent(fileName)
|
||
if FileManager.default.fileExists(atPath: destURL.path) {
|
||
print("⚠️ File already exists, skipping save")
|
||
return
|
||
}
|
||
do {
|
||
try FileManager.default.copyItem(at: sourceURL, to: destURL)
|
||
print("✅ Saved stash thumbnail to \(destURL.path)")
|
||
} catch {
|
||
print("❌ Error saving stash thumbnail: \(error)")
|
||
}
|
||
}
|
||
|
||
// NIEUW: Helper functie om gecachte delegate te krijgen
|
||
private func getDragDelegate(for item: IdentifiableImage) -> StashDragDelegate {
|
||
if let cachedDelegate = dragDelegateCache[item.id] {
|
||
return cachedDelegate
|
||
} else {
|
||
let newDelegate = StashDragDelegate(imageItem: item, imageStore: imageStore)
|
||
dragDelegateCache[item.id] = newDelegate
|
||
|
||
// Set the stash grid manager reference ONCE when creating the delegate
|
||
// For now, we'll set it later when the actual drag starts since the stash grid manager
|
||
// might not be created yet when the delegate is first created
|
||
|
||
return newDelegate
|
||
}
|
||
}
|
||
|
||
// ULTRA REACTIVE: Computed property die automatisch update wanneer hoveredImageID verandert
|
||
private var currentHoveredImageName: String {
|
||
guard let hoveredID = hoveredImageID,
|
||
let hoveredItem = imageStore.images.first(where: { $0.id == hoveredID }) else {
|
||
return "Unknown"
|
||
}
|
||
let name = hoveredItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown"
|
||
print("🔥 ULTRA DEBUG: currentHoveredImageName computed - hoveredID: \(hoveredID), name: '\(name)'")
|
||
return name
|
||
}
|
||
}
|
||
|
||
// MARK: - StashDragDelegate Class
|
||
class StashDragDelegate: StashDraggableImageViewDelegate {
|
||
let imageItem: IdentifiableImage
|
||
let imageStore: GalleryImageStore
|
||
var stashGridActionDelegate: StashGridActionDelegate? // Strong reference to prevent deallocation during operations
|
||
|
||
init(imageItem: IdentifiableImage, imageStore: GalleryImageStore) {
|
||
self.imageItem = imageItem
|
||
self.imageStore = imageStore
|
||
|
||
// Create a completely independent StashGridActionDelegate
|
||
// BELANGRIJK: Houd sterke reference om deallocation tijdens rename te voorkomen
|
||
self.stashGridActionDelegate = StashGridActionDelegate(imageStore: imageStore)
|
||
|
||
// NIEUW: Set the presenting window for proper dialog positioning
|
||
DispatchQueue.main.async { [weak self] in
|
||
// Find the stash window
|
||
for window in NSApp.windows {
|
||
if window.title.contains("Stash") || window.contentView?.identifier?.rawValue.contains("stash") == true {
|
||
self?.stashGridActionDelegate?.presentingWindow = window
|
||
break
|
||
}
|
||
}
|
||
|
||
// Fallback: use any visible window
|
||
if self?.stashGridActionDelegate?.presentingWindow == nil {
|
||
self?.stashGridActionDelegate?.presentingWindow = NSApp.windows.first { $0.isVisible }
|
||
}
|
||
}
|
||
}
|
||
|
||
// NIEUW: Set stash grid manager reference for proper grid frame positioning
|
||
func setStashGridManager(_ manager: StashGridManager?) {
|
||
// Only set if not already set to avoid excessive logging
|
||
guard stashGridActionDelegate?.stashGridManager !== manager else { return }
|
||
|
||
stashGridActionDelegate?.stashGridManager = manager
|
||
|
||
// KRITIEKE REPARATIE: Stel stashGridActionDelegate in als delegate van de manager
|
||
if let manager = manager, let actionDelegate = stashGridActionDelegate {
|
||
manager.delegate = actionDelegate
|
||
}
|
||
}
|
||
|
||
func stashImageDidStartDrag(imageURL: URL, from view: StashDraggableNSImageView) {
|
||
print("🎯 StashDragDelegate: Drag started for \(imageURL.lastPathComponent)")
|
||
print("🎯 StashDragDelegate: Grid delegate is \(stashGridActionDelegate != nil ? "SET" : "NIL")")
|
||
}
|
||
|
||
func stashImageDragDidEnd(imageURL: URL, operation: NSDragOperation, from view: StashDraggableNSImageView) {
|
||
print("🎯 StashDragDelegate: Drag ended for \(imageURL.lastPathComponent) with operation \(operation.rawValue)")
|
||
print("🎯 StashDragDelegate: Grid delegate is \(stashGridActionDelegate != nil ? "SET" : "NIL") after drag end")
|
||
}
|
||
}
|
||
|
||
// MARK: - Stash Grid Action Delegate
|
||
class StashGridActionDelegate: StashGridDelegate, RenameActionHandlerDelegate {
|
||
let imageStore: GalleryImageStore
|
||
weak var presentingWindow: NSWindow?
|
||
private var renameActionHandler: RenameActionHandler?
|
||
|
||
// NIEUW: Reference naar stash grid manager voor correcte grid frame
|
||
weak var stashGridManager: StashGridManager?
|
||
|
||
init(imageStore: GalleryImageStore) {
|
||
self.imageStore = imageStore
|
||
self.renameActionHandler = RenameActionHandler(delegate: self)
|
||
}
|
||
|
||
func stashGridDidDropImage(at cellIndex: Int, stashItem: IdentifiableImage, imageURL: URL) {
|
||
print("✅ StashGridActionDelegate: Independent action for cell \(cellIndex), stash item \(stashItem.id)")
|
||
|
||
// 🔄 NEW: Set stash grid action flag for DraggableImageView post-action handling
|
||
if let appDelegate = NSApp.delegate as? ScreenshotApp {
|
||
appDelegate.didStashGridHandleDrop = true
|
||
print("🔄 Set didStashGridHandleDrop = true for stash action")
|
||
}
|
||
|
||
// CRITICAL FIX: Use dynamic action mapping instead of hardcoded cellIndex
|
||
let settings = SettingsManager.shared
|
||
|
||
// Get the active actions in the same order as StashGridWindow creates them
|
||
var activeActionTypes: [ActionType] = []
|
||
for actionType in settings.actionOrder {
|
||
let isEnabled: Bool
|
||
switch actionType {
|
||
case .rename: isEnabled = settings.isRenameActionEnabled
|
||
case .stash: isEnabled = settings.isStashActionEnabled
|
||
case .ocr: isEnabled = settings.isOCRActionEnabled
|
||
case .clipboard: isEnabled = settings.isClipboardActionEnabled
|
||
case .backgroundRemove: isEnabled = settings.isBackgroundRemoveActionEnabled
|
||
case .cancel: isEnabled = settings.isCancelActionEnabled
|
||
case .remove: isEnabled = settings.isRemoveActionEnabled
|
||
}
|
||
|
||
if isEnabled {
|
||
activeActionTypes.append(actionType)
|
||
}
|
||
}
|
||
|
||
// Map cellIndex to actual ActionType
|
||
guard cellIndex >= 0 && cellIndex < activeActionTypes.count else {
|
||
print("⚠️ StashGridActionDelegate: Invalid cell index \(cellIndex), available actions: \(activeActionTypes.count)")
|
||
return
|
||
}
|
||
|
||
let actionType = activeActionTypes[cellIndex]
|
||
print("🎯 StashGridActionDelegate: Cell \(cellIndex) maps to action: \(actionType)")
|
||
|
||
// Execute action based on ActionType instead of cellIndex
|
||
switch actionType {
|
||
case .rename:
|
||
handleStashRename(stashItem: stashItem, imageURL: imageURL)
|
||
case .stash:
|
||
handleStashDuplicate(stashItem: stashItem, imageURL: imageURL) // Stash = duplicate for stash items
|
||
case .clipboard:
|
||
handleStashClipboard(stashItem: stashItem, imageURL: imageURL)
|
||
case .ocr:
|
||
handleStashOCR(stashItem: stashItem, imageURL: imageURL)
|
||
case .backgroundRemove:
|
||
handleStashBackgroundRemove(stashItem: stashItem, imageURL: imageURL)
|
||
case .remove:
|
||
handleStashRemove(stashItem: stashItem, imageURL: imageURL)
|
||
case .cancel:
|
||
handleStashClose()
|
||
}
|
||
}
|
||
|
||
// MARK: - Independent Stash Actions
|
||
|
||
private func handleStashRename(stashItem: IdentifiableImage, imageURL: URL) {
|
||
print("📝 StashGridActionDelegate: Using real RenameActionHandler for stash item \(stashItem.id)")
|
||
|
||
// Store current stash item for completion handling
|
||
self.currentStashItem = stashItem
|
||
|
||
// Use the real RenameActionHandler just like the main thumbnail
|
||
renameActionHandler?.promptAndRename(originalURL: imageURL) { [self] response in
|
||
print("🔍 DEBUG: Stash rename response: \(response), successful: \(response != .cancel)")
|
||
|
||
// Handle Save to Folder action (second button)
|
||
if response == .continue {
|
||
print("🔍 DEBUG: Processing Save to Folder for stash item")
|
||
if let updatedURL = self.currentStashItem?.fileURL ?? imageURL as URL? {
|
||
self.saveToFolder(fileURL: updatedURL) { success in
|
||
print("🔍 DEBUG: Save to Folder result: \(success)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Reset current stash item ALLEEN als de response succesvol was
|
||
if response != .cancel {
|
||
print("🔍 DEBUG: Resetting currentStashItem after successful rename")
|
||
}
|
||
self.currentStashItem = nil
|
||
}
|
||
}
|
||
|
||
// Store current stash item for rename completion
|
||
private var currentStashItem: IdentifiableImage?
|
||
|
||
// MARK: - RenameActionHandlerDelegate Implementation
|
||
|
||
func getScreenshotFolder() -> String? {
|
||
return SettingsManager.shared.screenshotFolder
|
||
}
|
||
|
||
func renameActionHandler(_ handler: RenameActionHandler, didRenameFileFrom oldURL: URL, to newURL: URL) {
|
||
print("✅ Stash item renamed from \(oldURL.lastPathComponent) to \(newURL.lastPathComponent)")
|
||
|
||
// Update the current stash item in GalleryImageStore
|
||
guard let stashItem = currentStashItem else {
|
||
print("⚠️ No current stash item to update after rename")
|
||
return
|
||
}
|
||
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
if let index = self.imageStore.images.firstIndex(where: { $0.id == stashItem.id }) {
|
||
let newName = newURL.deletingPathExtension().lastPathComponent
|
||
self.imageStore.images[index].customName = newName
|
||
self.imageStore.images[index].fileURL = newURL
|
||
print("✅ Updated stash item in store with customName: '\(newName)'")
|
||
|
||
// NIEUW: Force UI update door de hele array te 'verversen'
|
||
self.imageStore.objectWillChange.send()
|
||
}
|
||
}
|
||
}
|
||
|
||
// NIEUW: Implement Save to Folder functionality for stash items
|
||
func saveToFolder(fileURL: URL, completion: @escaping (Bool) -> Void) {
|
||
guard let destinationFolder = SettingsManager.shared.screenshotFolder, !destinationFolder.isEmpty else {
|
||
print("❌ No screenshot folder set for stash save")
|
||
DispatchQueue.main.async {
|
||
let alert = NSAlert()
|
||
alert.messageText = "Folder Not Set"
|
||
alert.informativeText = "Please set a default save folder in Settings."
|
||
alert.addButton(withTitle: "OK")
|
||
alert.runModal()
|
||
completion(false)
|
||
}
|
||
return
|
||
}
|
||
|
||
let destinationURL = URL(fileURLWithPath: destinationFolder).appendingPathComponent(fileURL.lastPathComponent)
|
||
|
||
do {
|
||
// Check if file already exists
|
||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||
print("⚠️ File already exists at destination: \(destinationURL.lastPathComponent)")
|
||
DispatchQueue.main.async {
|
||
let alert = NSAlert()
|
||
alert.messageText = "File Exists"
|
||
alert.informativeText = "A file with the name '\(destinationURL.lastPathComponent)' already exists in the destination folder."
|
||
alert.addButton(withTitle: "OK")
|
||
alert.runModal()
|
||
completion(false)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Copy file to destination
|
||
try FileManager.default.copyItem(at: fileURL, to: destinationURL)
|
||
print("✅ Stash item saved to folder: \(destinationURL.path)")
|
||
|
||
DispatchQueue.main.async {
|
||
completion(true)
|
||
}
|
||
} catch {
|
||
print("❌ Failed to save stash item to folder: \(error)")
|
||
DispatchQueue.main.async {
|
||
let alert = NSAlert()
|
||
alert.messageText = "Save Failed"
|
||
alert.informativeText = "Could not save the file: \(error.localizedDescription)"
|
||
alert.addButton(withTitle: "OK")
|
||
alert.runModal()
|
||
completion(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
func findFilenameLabel(in window: NSWindow?) -> NSTextField? {
|
||
// For stash items, we don't have a filename label like the main preview
|
||
return nil
|
||
}
|
||
|
||
func setTempFileURL(_ url: URL?) {
|
||
// For stash items, update the current item's fileURL
|
||
guard let stashItem = currentStashItem, let newURL = url else { return }
|
||
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
if let index = self.imageStore.images.firstIndex(where: { $0.id == stashItem.id }) {
|
||
self.imageStore.images[index].fileURL = newURL
|
||
}
|
||
}
|
||
}
|
||
|
||
func getActivePreviewWindow() -> NSWindow? {
|
||
return presentingWindow
|
||
}
|
||
|
||
func closePreviewWithAnimation(immediate: Bool, preserveTempFile: Bool = false) {
|
||
// For stash, we don't close the entire stash window during rename
|
||
print("🔍 Stash: closePreviewWithAnimation called (no action needed)")
|
||
}
|
||
|
||
func getGridWindowFrame() -> NSRect? {
|
||
// FIXED: Return the actual stash grid frame instead of entire stash window
|
||
if let stashGridFrame = stashGridManager?.gridWindow?.frame {
|
||
print("🔍 DEBUG: Using actual stash grid frame: \(stashGridFrame)")
|
||
return stashGridFrame
|
||
} else {
|
||
print("🔍 DEBUG: No stash grid window available, using presenting window frame")
|
||
return presentingWindow?.frame
|
||
}
|
||
}
|
||
|
||
func hideGrid() {
|
||
print("🔍 Stash: hideGrid called")
|
||
// The stash grid is handled by StashGridManager
|
||
}
|
||
|
||
func disableGridMonitoring() {
|
||
print("🔍 Stash: disableGridMonitoring called")
|
||
// Stash grid doesn't have proximity monitoring
|
||
}
|
||
|
||
func enableGridMonitoring() {
|
||
print("🔍 Stash: enableGridMonitoring called")
|
||
// Stash grid doesn't have proximity monitoring
|
||
}
|
||
|
||
private func handleStashDuplicate(stashItem: IdentifiableImage, imageURL: URL) {
|
||
print("➕ StashGridActionDelegate: Independent duplicate for stash item \(stashItem.id)")
|
||
|
||
// Create a new stash item (duplicate)
|
||
let newStashItem = IdentifiableImage(
|
||
nsImage: stashItem.nsImage,
|
||
fileURL: nil, // Will get permanent URL when added
|
||
customName: stashItem.customName
|
||
)
|
||
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
let baseName = stashItem.customName ?? "Image"
|
||
let uniqueName = self.imageStore.generateUniqueCopyName(baseName: baseName)
|
||
|
||
self.imageStore.addImage(newStashItem.nsImage, fileURL: nil, suggestedName: uniqueName, skipDuplicateCheck: true)
|
||
print("✅ Created duplicate stash item with name: '\(uniqueName)'")
|
||
|
||
// 🔥 ULTRA FIX: Show balloon feedback like hoofdgrid
|
||
if let gridFrame = self.getGridWindowFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Duplicated!")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Duplicated!", autoCloseAfter: nil)
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||
panel.closeWithAnimation(completion: nil)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleStashClipboard(stashItem: IdentifiableImage, imageURL: URL) {
|
||
print("📋 StashGridActionDelegate: Independent clipboard for stash item \(stashItem.id)")
|
||
|
||
guard let image = NSImage(contentsOf: imageURL) else {
|
||
print("❌ Could not load stash image for clipboard")
|
||
// 🔥 ULTRA FIX: Show error balloon
|
||
if let gridFrame = getGridWindowFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Image load failed for Clipboard", autoCloseAfter: 2.0)
|
||
}
|
||
return
|
||
}
|
||
|
||
let pasteboard = NSPasteboard.general
|
||
pasteboard.clearContents()
|
||
pasteboard.writeObjects([image])
|
||
print("✅ Stash image copied to clipboard")
|
||
|
||
// 🔥 ULTRA FIX: Show success balloon like hoofdgrid
|
||
if let gridFrame = getGridWindowFrame() {
|
||
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: nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleStashOCR(stashItem: IdentifiableImage, imageURL: URL) {
|
||
print("🧐 StashGridActionDelegate: Independent OCR for stash item \(stashItem.id)")
|
||
|
||
guard let nsImage = NSImage(contentsOf: imageURL),
|
||
let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||
print("❌ Could not load stash image for OCR")
|
||
// 🔥 ULTRA FIX: Show error balloon
|
||
if let gridFrame = getGridWindowFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Image load failed for OCR", autoCloseAfter: 2.0)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 🔥 ULTRA FIX: Show processing balloon like hoofdgrid
|
||
let processingPanel: FeedbackBubblePanel?
|
||
if let gridFrame = getGridWindowFrame() {
|
||
processingPanel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Processing...")
|
||
processingPanel?.show(aroundGridFrame: gridFrame, text: "Processing...", autoCloseAfter: nil)
|
||
} else {
|
||
processingPanel = nil
|
||
}
|
||
|
||
let request = VNRecognizeTextRequest { request, error in
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
// 🔥 ULTRA FIX: Close processing balloon first
|
||
processingPanel?.closeWithAnimation(completion: nil)
|
||
|
||
var message = ""
|
||
var ocrDidFindText = false
|
||
|
||
if let error = error {
|
||
print("❌ OCR failed: \(error.localizedDescription)")
|
||
message = "OCR error: \(error.localizedDescription)"
|
||
} else {
|
||
guard let observations = request.results as? [VNRecognizedTextObservation] else {
|
||
print("❌ No OCR results")
|
||
message = "No text found"
|
||
// 🔥 ULTRA FIX: Show result balloon
|
||
if let gridFrame = self.getGridWindowFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: message)
|
||
panel.show(aroundGridFrame: gridFrame, text: message, autoCloseAfter: 1.8, onAutoCloseCompletion: nil)
|
||
}
|
||
return
|
||
}
|
||
|
||
let recognizedText = observations.compactMap { observation in
|
||
observation.topCandidates(1).first?.string
|
||
}.joined(separator: "\n")
|
||
|
||
if recognizedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
print("📝 OCR found no readable text")
|
||
message = "No text found"
|
||
} else {
|
||
let pasteboard = NSPasteboard.general
|
||
pasteboard.clearContents()
|
||
pasteboard.setString(recognizedText, forType: .string)
|
||
print("✅ OCR text copied to clipboard: \(recognizedText.prefix(50))...")
|
||
message = "Text copied to clipboard!"
|
||
ocrDidFindText = true
|
||
}
|
||
}
|
||
|
||
// 🔥 ULTRA FIX: Always show result balloon
|
||
if let gridFrame = self.getGridWindowFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: message)
|
||
panel.show(aroundGridFrame: gridFrame, text: message, autoCloseAfter: 1.8, onAutoCloseCompletion: 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 handler failed: \(error.localizedDescription)")
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
// 🔥 ULTRA FIX: Close processing balloon first
|
||
processingPanel?.closeWithAnimation(completion: nil)
|
||
|
||
if let gridFrame = self.getGridWindowFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "OCR failed")
|
||
panel.show(aroundGridFrame: gridFrame, text: "OCR failed", autoCloseAfter: 2.0)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleStashBackgroundRemove(stashItem: IdentifiableImage, imageURL: URL) {
|
||
print("🪄 StashGridActionDelegate: BGR thumbnail workflow for stash item \(stashItem.id)")
|
||
|
||
guard let image = NSImage(contentsOf: imageURL) else {
|
||
print("❌ Could not load stash image for background removal")
|
||
return
|
||
}
|
||
|
||
// 🎨 NEW: Use new BGR thumbnail workflow instead of old BGR window
|
||
if let appDelegate = NSApp.delegate as? ScreenshotApp {
|
||
print("🎨 Starting BGR thumbnail workflow for stash item")
|
||
appDelegate.showBackgroundRemovalThumbnail(with: image, originalURL: imageURL)
|
||
print("✅ Started BGR thumbnail workflow for stash item")
|
||
} else {
|
||
print("❌ Could not get app delegate for BGR thumbnail workflow")
|
||
}
|
||
}
|
||
|
||
private func handleStashRemove(stashItem: IdentifiableImage, imageURL: URL) {
|
||
print("🗑️ StashGridActionDelegate: Independent remove for stash item \(stashItem.id)")
|
||
|
||
// Remove from file system
|
||
do {
|
||
try FileManager.default.removeItem(at: imageURL)
|
||
|
||
// Remove from GalleryImageStore
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
self.imageStore.images.removeAll { $0.id == stashItem.id }
|
||
print("✅ Removed stash item from store")
|
||
|
||
// 🔥 ULTRA FIX: Show success balloon like hoofdgrid
|
||
if let gridFrame = self.getGridWindowFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Removed!")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Item removed!", autoCloseAfter: 1.5)
|
||
}
|
||
}
|
||
} catch {
|
||
print("❌ Failed to remove stash item: \(error)")
|
||
// 🔥 ULTRA FIX: Show error balloon
|
||
if let gridFrame = getGridWindowFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Failed to remove item", autoCloseAfter: 2.0)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleStashClose() {
|
||
print("❌ StashGridActionDelegate: Independent close stash")
|
||
|
||
// Find and close the stash window
|
||
DispatchQueue.main.async {
|
||
for window in NSApp.windows {
|
||
if window.title.contains("Stash") {
|
||
window.close()
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
struct IntegratedGalleryView_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
let store = GalleryImageStore()
|
||
let sampleImage = NSImage(systemSymbolName: "photo", accessibilityDescription: nil) ?? NSImage()
|
||
return IntegratedGalleryView(imageStore: store, initialImage: sampleImage, initialImageURL: nil, initialImageName: nil, hostingWindow: nil, closeAction: { print("Preview close action") })
|
||
.frame(width: 250, height: 300)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
extension IntegratedGalleryView: Identifiable {
|
||
var id: UUID {
|
||
return galleryID
|
||
}
|
||
// _galleryID is al gedefinieerd in de struct.
|
||
}
|
||
|
||
// MARK: - OptimizedStashItemView
|
||
struct OptimizedStashItemView: View {
|
||
let imageItem: IdentifiableImage
|
||
let calculatedThumbnailSize: CGFloat
|
||
let thumbnailCornerRadius: CGFloat
|
||
let hoverScaleEffect: CGFloat
|
||
|
||
@Binding var hoveredImageID: UUID?
|
||
@Binding var currentPreviewName: String // TURBO: Instant naam binding
|
||
@Binding var imageForPreview: NSImage?
|
||
@Binding var showPreview: Bool
|
||
@Binding var isPreviewStable: Bool
|
||
@Binding var hoverWorkItem: DispatchWorkItem?
|
||
@Binding var stableDelegateCache: [UUID: StashDragDelegate]
|
||
|
||
let imageStore: GalleryImageStore
|
||
let onRemove: (IdentifiableImage) -> Void
|
||
|
||
// CRITICAL FIX: Stable state to prevent re-renders
|
||
@State private var tempURL: URL?
|
||
@State private var isInitialized = false
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .topTrailing) {
|
||
// Main draggable image view
|
||
if let url = tempURL {
|
||
StashDraggableImageView(
|
||
nsImage: imageItem.nsImage,
|
||
imageURL: url,
|
||
suggestedName: displayNameForStashItem(imageItem),
|
||
stashItem: imageItem,
|
||
delegate: getStableDelegate()
|
||
)
|
||
.frame(width: calculatedThumbnailSize, height: calculatedThumbnailSize)
|
||
.clipShape(RoundedRectangle(cornerRadius: thumbnailCornerRadius))
|
||
.scaleEffect(hoveredImageID == imageItem.id ? hoverScaleEffect : 1.0)
|
||
.animation(.easeInOut(duration: 0.08), value: hoveredImageID == imageItem.id)
|
||
.zIndex(hoveredImageID == imageItem.id ? 1 : 0)
|
||
.allowsHitTesting(true)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(count: 2) {
|
||
openImageInSystemApp(imageItem, tempURL: url)
|
||
}
|
||
} else {
|
||
// Fallback image
|
||
Image(nsImage: imageItem.nsImage)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.cornerRadius(thumbnailCornerRadius)
|
||
.frame(width: calculatedThumbnailSize, height: calculatedThumbnailSize)
|
||
.clipped()
|
||
.scaleEffect(hoveredImageID == imageItem.id ? hoverScaleEffect : 1.0)
|
||
.animation(.easeInOut(duration: 0.08), value: hoveredImageID == imageItem.id)
|
||
.zIndex(hoveredImageID == imageItem.id ? 1 : 0)
|
||
.allowsHitTesting(true)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
// Remove button (X)
|
||
Button(action: {
|
||
onRemove(imageItem)
|
||
}) {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundColor(.black)
|
||
.background(Color.white.opacity(0.6))
|
||
.clipShape(Circle())
|
||
.font(.system(size: 13))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.offset(x: 3, y: -3)
|
||
.opacity(hoveredImageID == imageItem.id ? 1 : 0)
|
||
.animation(.easeInOut(duration: 0.08), value: hoveredImageID == imageItem.id)
|
||
.zIndex(2)
|
||
.allowsHitTesting(hoveredImageID == imageItem.id)
|
||
}
|
||
.frame(width: calculatedThumbnailSize, height: calculatedThumbnailSize)
|
||
.background(Color.clear)
|
||
.contentShape(Rectangle())
|
||
.onHover { isHovering in
|
||
// OPTIMIZED: Minimal state changes
|
||
hoverWorkItem?.cancel()
|
||
|
||
if isHovering {
|
||
self.hoveredImageID = imageItem.id
|
||
// 🚀 TURBO INSTANT UPDATE: Zet naam METEEN zonder delay!
|
||
self.currentPreviewName = imageItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown"
|
||
print("🔍 ULTRA DEBUG: onHover START - set hoveredImageID to \(imageItem.id) with INSTANT name: '\(self.currentPreviewName)' from item: \(imageItem.fileURL?.lastPathComponent ?? "nil")")
|
||
self.imageForPreview = imageItem.nsImage
|
||
self.showPreview = true
|
||
self.isPreviewStable = true
|
||
} else {
|
||
if self.hoveredImageID == imageItem.id {
|
||
print("🔍 ULTRA DEBUG: onHover END - clearing hover for \(imageItem.id)")
|
||
let workItem = DispatchWorkItem {
|
||
if self.hoveredImageID == imageItem.id {
|
||
self.hoveredImageID = nil
|
||
self.currentPreviewName = "Unknown" // Reset naam ook
|
||
self.imageForPreview = nil
|
||
self.showPreview = false
|
||
self.isPreviewStable = false
|
||
}
|
||
}
|
||
self.hoverWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
// CRITICAL FIX: Initialize only once
|
||
if !isInitialized {
|
||
initializeTempURL()
|
||
isInitialized = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// CRITICAL FIX: Stable delegate getter that prevents recreation
|
||
private func getStableDelegate() -> StashDragDelegate {
|
||
if let existingDelegate = stableDelegateCache[imageItem.id] {
|
||
return existingDelegate
|
||
} else {
|
||
let newDelegate = StashDragDelegate(imageItem: imageItem, imageStore: imageStore)
|
||
stableDelegateCache[imageItem.id] = newDelegate
|
||
return newDelegate
|
||
}
|
||
}
|
||
|
||
private func initializeTempURL() {
|
||
if let existingURL = imageItem.fileURL {
|
||
tempURL = existingURL
|
||
} else {
|
||
tempURL = createTempURLForStashItem(imageItem)
|
||
}
|
||
}
|
||
|
||
private func createTempURLForStashItem(_ imgItem: IdentifiableImage) -> URL? {
|
||
if let existingURL = imgItem.fileURL {
|
||
return existingURL
|
||
}
|
||
|
||
guard let tiffRepresentation = imgItem.nsImage.tiffRepresentation,
|
||
let bitmapImageRep = NSBitmapImageRep(data: tiffRepresentation),
|
||
let pngData = bitmapImageRep.representation(using: .png, properties: [:]) else {
|
||
return nil
|
||
}
|
||
|
||
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
||
let tempFilename = "\(imgItem.id.uuidString).png"
|
||
let tempFileURL = tempDirectoryURL.appendingPathComponent(tempFilename)
|
||
|
||
do {
|
||
try pngData.write(to: tempFileURL)
|
||
return tempFileURL
|
||
} catch {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func openImageInSystemApp(_ imgItem: IdentifiableImage, tempURL: URL) {
|
||
NSWorkspace.shared.open(tempURL)
|
||
}
|
||
|
||
private func displayNameForStashItem(_ imgItem: IdentifiableImage) -> String? {
|
||
if let customName = imgItem.customName, !customName.isEmpty {
|
||
return customName
|
||
}
|
||
|
||
if let fileURL = imgItem.fileURL {
|
||
return fileURL.deletingPathExtension().lastPathComponent
|
||
}
|
||
|
||
return nil
|
||
}
|
||
} |