mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
371 lines
13 KiB
Swift
371 lines
13 KiB
Swift
//
|
|
// ShelfItemView.swift
|
|
// boringNotch
|
|
//
|
|
// Created by Alexander on 2025-09-24.
|
|
//
|
|
|
|
import AppKit
|
|
import SwiftUI
|
|
import Defaults
|
|
|
|
import QuickLook
|
|
|
|
struct ShelfItemView: View {
|
|
let item: ShelfItem
|
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
|
@ObservedObject var selection = ShelfSelectionModel.shared
|
|
@StateObject private var viewModel: ShelfItemViewModel
|
|
@EnvironmentObject private var quickLookService: QuickLookService
|
|
@State private var showStack = false
|
|
@State private var cachedPreviewImage: NSImage?
|
|
@State private var debouncedDropTarget = false
|
|
|
|
private var isSelected: Bool { viewModel.isSelected }
|
|
private var shouldHideDuringDrag: Bool { selection.isDragging && selection.isSelected(item.id) && false }
|
|
|
|
init(item: ShelfItem) {
|
|
self.item = item
|
|
_viewModel = StateObject(wrappedValue: ShelfItemViewModel(item: item))
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
if !shouldHideDuringDrag {
|
|
VStack(alignment: .center, spacing: 2) {
|
|
iconView
|
|
textView
|
|
}
|
|
.frame(width: 105)
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 5)
|
|
.background(backgroundView)
|
|
.contentShape(Rectangle())
|
|
.animation(.easeInOut(duration: 0.1), value: debouncedDropTarget)
|
|
.animation(.easeInOut(duration: 0.1), value: isSelected)
|
|
|
|
DraggableClickHandler(
|
|
item: item,
|
|
viewModel: viewModel,
|
|
cachedPreviewImage: $cachedPreviewImage,
|
|
dragPreviewContent: {
|
|
DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName)
|
|
},
|
|
onRightClick: viewModel.handleRightClick,
|
|
onClick: { event, nsview in
|
|
viewModel.handleClick(event: event, view: nsview)
|
|
}
|
|
)
|
|
} else {
|
|
Color.clear
|
|
.frame(width: 105)
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 5)
|
|
}
|
|
}
|
|
.onChange(of: viewModel.isDropTargeted) { _, targeted in
|
|
vm.dragDetectorTargeting = targeted
|
|
// Debounce drop target state changes
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(50))
|
|
debouncedDropTarget = targeted
|
|
}
|
|
}
|
|
.onAppear {
|
|
Task {
|
|
await viewModel.loadThumbnail()
|
|
// Pre-render drag preview once on appear
|
|
if cachedPreviewImage == nil {
|
|
cachedPreviewImage = await renderDragPreview()
|
|
}
|
|
}
|
|
viewModel.onQuickLookRequest = { urls in
|
|
quickLookService.show(urls: urls, selectFirst: true)
|
|
}
|
|
}
|
|
.onChange(of: viewModel.thumbnail) { _, _ in
|
|
// Invalidate cached preview when thumbnail changes
|
|
Task {
|
|
cachedPreviewImage = await renderDragPreview()
|
|
}
|
|
}
|
|
.quickLookPresenter(using: quickLookService)
|
|
}
|
|
|
|
// MARK: - View Components
|
|
|
|
private var iconView: some View {
|
|
Image(nsImage: viewModel.thumbnail ?? item.icon)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 56, height: 56)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2)
|
|
}
|
|
|
|
private var textView: some View {
|
|
Text(item.displayName)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
.multilineTextAlignment(.center)
|
|
.frame(height: 30, alignment: .top)
|
|
}
|
|
|
|
private var backgroundView: some View {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(backgroundColor)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(
|
|
strokeColor,
|
|
lineWidth: strokeWidth
|
|
)
|
|
)
|
|
}
|
|
|
|
private var backgroundColor: Color {
|
|
if debouncedDropTarget {
|
|
return Color.accentColor.opacity(0.25)
|
|
} else if isSelected {
|
|
return Color.accentColor.opacity(0.15)
|
|
} else {
|
|
return Color.clear
|
|
}
|
|
}
|
|
|
|
private var strokeColor: Color {
|
|
if debouncedDropTarget {
|
|
return Color.accentColor.opacity(0.9)
|
|
} else if isSelected {
|
|
return Color.accentColor.opacity(0.8)
|
|
} else {
|
|
return Color.clear
|
|
}
|
|
}
|
|
|
|
private var strokeWidth: CGFloat {
|
|
if debouncedDropTarget {
|
|
return 3
|
|
} else if isSelected {
|
|
return 2
|
|
} else {
|
|
return 1
|
|
}
|
|
}
|
|
|
|
// MARK: - Drag Preview Rendering
|
|
|
|
@MainActor
|
|
private func renderDragPreview() async -> NSImage {
|
|
let content = DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName)
|
|
let renderer = ImageRenderer(content: content)
|
|
renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0
|
|
return renderer.nsImage ?? (viewModel.thumbnail ?? item.icon)
|
|
}
|
|
|
|
|
|
}
|
|
|
|
// MARK: - Draggable Click Handler with NSDraggingSource
|
|
private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
|
|
let item: ShelfItem
|
|
let viewModel: ShelfItemViewModel
|
|
@Binding var cachedPreviewImage: NSImage?
|
|
@ViewBuilder let dragPreviewContent: () -> Content
|
|
let onRightClick: (NSEvent, NSView) -> Void
|
|
let onClick: (NSEvent, NSView) -> Void
|
|
|
|
func makeNSView(context: Context) -> DraggableClickView {
|
|
let view = DraggableClickView()
|
|
view.item = item
|
|
view.viewModel = viewModel
|
|
view.dragPreviewImage = cachedPreviewImage ?? renderDragPreview()
|
|
view.onRightClick = onRightClick
|
|
view.onClick = onClick
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: DraggableClickView, context: Context) {
|
|
nsView.item = item
|
|
nsView.viewModel = viewModel
|
|
// Only update preview if cached version is available
|
|
if let cached = cachedPreviewImage {
|
|
nsView.dragPreviewImage = cached
|
|
}
|
|
nsView.onRightClick = onRightClick
|
|
nsView.onClick = onClick
|
|
}
|
|
|
|
private func renderDragPreview() -> NSImage {
|
|
let content = dragPreviewContent()
|
|
let renderer = ImageRenderer(content: content)
|
|
renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0
|
|
|
|
if let nsImage = renderer.nsImage {
|
|
return nsImage
|
|
}
|
|
|
|
// Fallback to icon if rendering fails
|
|
return viewModel.thumbnail ?? item.icon
|
|
}
|
|
|
|
final class DraggableClickView: NSView, NSDraggingSource {
|
|
var item: ShelfItem!
|
|
weak var viewModel: ShelfItemViewModel?
|
|
var dragPreviewImage: NSImage?
|
|
var onRightClick: ((NSEvent, NSView) -> Void)?
|
|
var onClick: ((NSEvent, NSView) -> Void)?
|
|
|
|
private var mouseDownEvent: NSEvent?
|
|
private let dragThreshold: CGFloat = 3.0
|
|
private var draggedURLs: [URL] = []
|
|
private var draggedItems: [ShelfItem] = []
|
|
|
|
override func rightMouseDown(with event: NSEvent) {
|
|
onRightClick?(event, self)
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
mouseDownEvent = event
|
|
onClick?(event, self)
|
|
}
|
|
|
|
override func mouseDragged(with event: NSEvent) {
|
|
guard let mouseDownEvent = mouseDownEvent else {
|
|
super.mouseDragged(with: event)
|
|
return
|
|
}
|
|
|
|
let dragDistance = hypot(
|
|
event.locationInWindow.x - mouseDownEvent.locationInWindow.x,
|
|
event.locationInWindow.y - mouseDownEvent.locationInWindow.y
|
|
)
|
|
|
|
if dragDistance > dragThreshold {
|
|
startDragSession(with: event)
|
|
self.mouseDownEvent = nil
|
|
} else {
|
|
super.mouseDragged(with: event)
|
|
}
|
|
}
|
|
|
|
private func startDragSession(with event: NSEvent) {
|
|
// Prepare dragging items
|
|
let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items)
|
|
let itemsToDrag: [ShelfItem]
|
|
|
|
if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) {
|
|
itemsToDrag = selectedItems
|
|
} else {
|
|
itemsToDrag = [item]
|
|
}
|
|
|
|
// Store items being dragged for auto-remove feature
|
|
draggedItems = itemsToDrag
|
|
|
|
// Create dragging items for AppKit
|
|
var draggingItems: [NSDraggingItem] = []
|
|
|
|
for dragItem in itemsToDrag {
|
|
if let pasteboardItem = createPasteboardItem(for: dragItem) {
|
|
let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
|
|
|
|
// Use the drag preview image
|
|
let image = dragPreviewImage ?? dragItem.icon
|
|
let imageFrame = NSRect(
|
|
x: 0,
|
|
y: 0,
|
|
width: image.size.width,
|
|
height: image.size.height
|
|
)
|
|
draggingItem.setDraggingFrame(imageFrame, contents: image)
|
|
|
|
draggingItems.append(draggingItem)
|
|
}
|
|
}
|
|
|
|
guard !draggingItems.isEmpty else { return }
|
|
|
|
beginDraggingSession(with: draggingItems, event: event, source: self)
|
|
}
|
|
|
|
private func createPasteboardItem(for item: ShelfItem) -> NSPasteboardItem? {
|
|
let pasteboardItem = NSPasteboardItem()
|
|
|
|
switch item.kind {
|
|
case .file:
|
|
guard let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) else {
|
|
pasteboardItem.setString(item.displayName, forType: .string)
|
|
return pasteboardItem
|
|
}
|
|
|
|
// Start accessing security-scoped resource and keep it active during drag
|
|
if url.startAccessingSecurityScopedResource() {
|
|
draggedURLs.append(url)
|
|
NSLog("🔐 Started security-scoped access for drag: \(url.path)")
|
|
}
|
|
|
|
pasteboardItem.setString(url.absoluteString, forType: .fileURL)
|
|
pasteboardItem.setString(url.path, forType: .string)
|
|
return pasteboardItem
|
|
|
|
case .text(let string):
|
|
pasteboardItem.setString(string, forType: .string)
|
|
return pasteboardItem
|
|
|
|
case .link(let url):
|
|
pasteboardItem.setString(url.absoluteString, forType: .URL)
|
|
pasteboardItem.setString(url.absoluteString, forType: .string)
|
|
return pasteboardItem
|
|
}
|
|
}
|
|
|
|
// MARK: - NSDraggingSource
|
|
|
|
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
|
// When copyOnDrag is enabled, only allow copy operations
|
|
if Defaults[.copyOnDrag] {
|
|
return [.copy]
|
|
}
|
|
|
|
switch context {
|
|
case .outsideApplication:
|
|
return [.copy, .move]
|
|
case .withinApplication:
|
|
return [.copy, .move, .generic]
|
|
@unknown default:
|
|
return [.copy]
|
|
}
|
|
}
|
|
|
|
func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) {
|
|
ShelfSelectionModel.shared.beginDrag()
|
|
}
|
|
|
|
|
|
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
|
ShelfSelectionModel.shared.endDrag()
|
|
|
|
// Stop accessing security-scoped resources after drag completes
|
|
for url in draggedURLs {
|
|
url.stopAccessingSecurityScopedResource()
|
|
NSLog("🔐 Stopped security-scoped access after drag: \(url.path)")
|
|
}
|
|
draggedURLs.removeAll()
|
|
|
|
// Auto-remove items from shelf if enabled and drag succeeded
|
|
if Defaults[.autoRemoveShelfItems] && !operation.isEmpty {
|
|
for item in draggedItems {
|
|
ShelfStateViewModel.shared.remove(item)
|
|
}
|
|
}
|
|
draggedItems.removeAll()
|
|
}
|
|
|
|
func ignoreModifierKeys(for session: NSDraggingSession) -> Bool {
|
|
return false
|
|
}
|
|
}
|
|
}
|