mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 15:44:32 +00:00
Somewhat working file tray
This commit is contained in:
parent
4c7e79b954
commit
30422c3d95
29 changed files with 4032 additions and 65 deletions
29
DynamicIsland/components/Shelf/Views/DragPreviewView.swift
Normal file
29
DynamicIsland/components/Shelf/Views/DragPreviewView.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct DragPreviewView: View {
|
||||
let thumbnail: NSImage?
|
||||
let displayName: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 4) {
|
||||
Image(nsImage: thumbnail ?? NSImage())
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text(displayName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(Color.accentColor))
|
||||
.frame(alignment: .top)
|
||||
}
|
||||
.frame(width: 105)
|
||||
}
|
||||
}
|
||||
135
DynamicIsland/components/Shelf/Views/FileShareView.swift
Normal file
135
DynamicIsland/components/Shelf/Views/FileShareView.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
//
|
||||
// FileShareView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct FileShareView: View {
|
||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||
@StateObject private var quickShare = QuickShareService.shared
|
||||
@Default(.quickShareProvider) var quickShareProvider: String
|
||||
|
||||
@State private var hostView: NSView?
|
||||
@State private var interactionNonce: UUID = .init()
|
||||
@State private var isProcessing = false
|
||||
|
||||
private var selectedProvider: QuickShareProvider {
|
||||
quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
dropArea
|
||||
.background(NSViewHost(view: $hostView))
|
||||
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in
|
||||
interactionNonce = .init()
|
||||
vm.dropEvent = true
|
||||
Task { await handleDrop(providers) }
|
||||
return true
|
||||
}
|
||||
.onTapGesture {
|
||||
Task {
|
||||
await handleClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dropArea: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(
|
||||
LinearGradient(colors: [Color.black.opacity(0.35), Color.black.opacity(0.20)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(
|
||||
vm.dropZoneTargeting
|
||||
? Color.accentColor.opacity(0.9)
|
||||
: Color.white.opacity(0.1),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
|
||||
)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.6), radius: 6, x: 0, y: 2)
|
||||
|
||||
// Content
|
||||
VStack(spacing: 5) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(
|
||||
vm.dropZoneTargeting ? 0.11 : 0.09
|
||||
))
|
||||
.frame(width: 55, height: 55)
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
Group {
|
||||
if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
|
||||
Image(nsImage: nsImg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
} else {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.frame(width: 34, height: 34)
|
||||
.foregroundStyle(
|
||||
vm.dropZoneTargeting ? Color.accentColor : Color.gray
|
||||
)
|
||||
.scaleEffect(
|
||||
vm.dropZoneTargeting ? 1.06 : 1.0
|
||||
)
|
||||
.animation(.spring(response: 0.36, dampingFraction: 0.7), value: vm.dropZoneTargeting)
|
||||
}
|
||||
|
||||
Text(selectedProvider.id)
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
}
|
||||
.padding(18)
|
||||
|
||||
// Loading overlay
|
||||
if isProcessing || quickShare.isPickerOpen {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.black.opacity(0.3))
|
||||
.overlay(
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.8)
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func handleDrop(_ providers: [NSItemProvider]) async {
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
await quickShare.shareDroppedFiles(providers, using: selectedProvider, from: hostView)
|
||||
}
|
||||
|
||||
private func handleClick() async {
|
||||
await quickShare.showFilePicker(for: selectedProvider, from: hostView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host NSView extractor for anchoring share sheet
|
||||
|
||||
private struct NSViewHost: NSViewRepresentable {
|
||||
@Binding var view: NSView?
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let v = NSView(frame: .zero)
|
||||
DispatchQueue.main.async { self.view = v }
|
||||
return v
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async { self.view = nsView }
|
||||
}
|
||||
}
|
||||
371
DynamicIsland/components/Shelf/Views/ShelfItemView.swift
Normal file
371
DynamicIsland/components/Shelf/Views/ShelfItemView.swift
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
115
DynamicIsland/components/Shelf/Views/ShelfView.swift
Normal file
115
DynamicIsland/components/Shelf/Views/ShelfView.swift
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//
|
||||
// ShelfItemView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct ShelfView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@StateObject var tvm = ShelfStateViewModel.shared
|
||||
@StateObject var selection = ShelfSelectionModel.shared
|
||||
@StateObject private var quickLookService = QuickLookService()
|
||||
private let spacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
FileShareView()
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.environmentObject(vm)
|
||||
panel
|
||||
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in
|
||||
handleDrop(providers: providers)
|
||||
}
|
||||
}
|
||||
// Bind Quick Look to shelf selection
|
||||
.onChange(of: selection.selectedIDs) {
|
||||
updateQuickLookSelection()
|
||||
}
|
||||
.quickLookPresenter(using: quickLookService)
|
||||
}
|
||||
|
||||
private func handleDrop(providers: [NSItemProvider]) -> Bool {
|
||||
guard !selection.isDragging else { return false }
|
||||
vm.dropEvent = true
|
||||
ShelfStateViewModel.shared.load(providers)
|
||||
return true
|
||||
}
|
||||
|
||||
private func updateQuickLookSelection() {
|
||||
guard quickLookService.isQuickLookOpen && !selection.selectedIDs.isEmpty else { return }
|
||||
|
||||
let selectedItems = selection.selectedItems(in: tvm.items)
|
||||
let urls: [URL] = selectedItems.compactMap { item in
|
||||
if let fileURL = item.fileURL {
|
||||
return fileURL
|
||||
}
|
||||
if case .link(let url) = item.kind {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !urls.isEmpty {
|
||||
quickLookService.updateSelection(urls: urls)
|
||||
}
|
||||
}
|
||||
|
||||
var panel: some View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(
|
||||
vm.dragDetectorTargeting
|
||||
? Color.accentColor.opacity(0.9)
|
||||
: Color.white.opacity(0.1),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
|
||||
)
|
||||
.overlay {
|
||||
content
|
||||
.padding()
|
||||
}
|
||||
.transaction { transaction in
|
||||
transaction.animation = vm.animation
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selection.clear() }
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
Group {
|
||||
if tvm.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "tray.and.arrow.down")
|
||||
.symbolVariant(.fill)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.white, .gray)
|
||||
.imageScale(.large)
|
||||
|
||||
Text("Drop files here")
|
||||
.foregroundStyle(.gray)
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
} else {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(tvm.items) { item in
|
||||
ShelfItemView(item: item)
|
||||
.environmentObject(quickLookService)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(-spacing)
|
||||
.scrollIndicators(.never)
|
||||
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in
|
||||
handleDrop(providers: providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ShelfStateViewModel.shared.cleanupInvalidItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue