Files
shotscreen/ShotScreen/Sources/IntegratedGalleryView.swift
Nick Roodenrijs 0dabed11d2 🎉 ShotScreen v1.0 - Initial Release
🚀 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.
2025-06-28 16:15:15 +02:00

2666 lines
124 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}