Files
KMix_Pro/KJamsClone/DJNativeDragDrop.swift

156 lines
9.0 KiB
Swift

import SwiftUI
import AppKit
enum DJPasteboardPayload {
static func song(_ pasteboard: NSPasteboard) -> SongDragReference? {
guard let text = pasteboard.string(forType:.string),text.hasPrefix("KJMix.song.v1:"),
let data = Data(base64Encoded:String(text.dropFirst("KJMix.song.v1:".count))) else { return nil }
return try? JSONDecoder().decode(SongDragReference.self,from:data)
}
static func files(_ pasteboard: NSPasteboard) -> [URL] {
(pasteboard.readObjects(forClasses:[NSURL.self],options:[.urlReadingFileURLsOnly:true]) as? [URL] ?? []).filter(\.isFileURL)
}
}
struct DJLibraryDragTitle: NSViewRepresentable {
let title: String
let reference: SongDragReference
let fontSize: Double
let select: () -> Void
var doubleClick: (() -> Void)? = nil
var selectAll: (() -> Void)? = nil
func makeNSView(context: Context) -> DJLibraryDragView { DJLibraryDragView() }
func updateNSView(_ view: DJLibraryDragView,context: Context) {
let redraw = view.title != title || view.fontSize != fontSize
view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select;view.doubleClick = doubleClick;view.selectAllTracks = selectAll
if redraw {
view.toolTip = "Double-click to load alternating decks, or drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true
}
}
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJLibraryDragView,context: Context) -> CGSize? { .init(width:proposal.width ?? 240,height:24) }
}
final class DJLibraryDragView: NSView {
var title = ""
var reference: SongDragReference?
var fontSize: Double = 11
var select: (() -> Void)?
var doubleClick: (() -> Void)?
var selectAllTracks: (() -> Void)?
override var acceptsFirstResponder: Bool { true }
@objc override func selectAll(_ sender: Any?) { selectAllTracks?() }
private var armed = false
private var dragging = false
override init(frame: NSRect) { super.init(frame:frame);setAccessibilityElement(true);setAccessibilityRole(.staticText) }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func draw(_ dirtyRect: NSRect) {
let paragraph = NSMutableParagraphStyle();paragraph.lineBreakMode = .byTruncatingTail
let font = NSFont.systemFont(ofSize:fontSize,weight:.medium)
let rect = NSRect(x:0,y:(bounds.height-ceil(font.ascender-font.descender))/2,width:bounds.width,height:ceil(font.ascender-font.descender)+2)
(title as NSString).draw(in:rect,withAttributes:[.font:font,.foregroundColor:NSColor.white,.paragraphStyle:paragraph])
}
override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self);armed = true;dragging = false }
override func mouseDragged(with event: NSEvent) {
guard armed else { return }
dragging = true
NSCursor.closedHand.set()
for target in DJNativeDropView.targets.allObjects where target.window === window {
let inside = !target.isHiddenOrHasHiddenAncestor && target.bounds.contains(target.convert(event.locationInWindow,from:nil))
target.layer?.borderWidth = inside ? 2 : 0
target.layer?.borderColor = target.tint.cgColor
}
}
override func mouseUp(with event: NSEvent) {
defer {
armed = false;dragging = false;NSCursor.arrow.set()
for target in DJNativeDropView.targets.allObjects { target.layer?.borderWidth = 0 }
}
guard dragging,let reference,let window else { select?();if armed,event.clickCount == 2 { doubleClick?() };return }
for target in DJNativeDropView.targets.allObjects where target.window === window && !target.isHiddenOrHasHiddenAncestor {
if target.bounds.contains(target.convert(event.locationInWindow,from:nil)),target.validSong?(reference) == true {
let receive = target.acceptSong
DispatchQueue.main.async { receive?(reference) }
break
}
}
}
}
struct DJDeckDropZone<Content: View>: NSViewRepresentable {
let system: DJSystem
let letter: String
let content: Content
init(system: DJSystem,letter: String,@ViewBuilder content: () -> Content) { self.system = system;self.letter = letter;self.content = content() }
func makeNSView(context: Context) -> DJNativeDropView {
let view = DJNativeDropView();updateNSView(view,context:context);return view
}
func updateNSView(_ view: DJNativeDropView,context: Context) {
view.host.rootView = AnyView(content.environment(\.colorScheme,context.environment.colorScheme))
view.tint = letter == "A" ? .systemCyan : .systemOrange
view.acceptSong = { [weak system] reference in
guard let system,reference.library == system.dragSession,let song = system.songs.first(where:{$0.id == reference.song}) else { return }
if letter == "Queue" { system.automix.enqueue([song]) } else { system.load(song,into:letter) }
}
view.acceptFiles = letter == "Queue" ? nil : { [weak system] urls in system?.requestFileLoad(urls,into:letter) }
view.validSong = { [weak system] reference in reference.library == system?.dragSession }
}
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJNativeDropView,context: Context) -> CGSize? {
.init(width:proposal.width ?? nsView.host.fittingSize.width,height:proposal.height ?? nsView.host.fittingSize.height)
}
}
final class DJNativeDropView: NSView {
static let targets = NSHashTable<DJNativeDropView>.weakObjects()
let host = DJDropHostingView(rootView:AnyView(EmptyView()))
var tint = NSColor.systemCyan
var acceptSong: ((SongDragReference) -> Void)?
var acceptFiles: (([URL]) -> Void)?
var validSong: ((SongDragReference) -> Bool)?
override init(frame: NSRect) {
super.init(frame:frame);Self.targets.add(self);registerForDraggedTypes([.string,.fileURL]);wantsLayer = true
host.dropTarget = self;host.registerForDraggedTypes([.string,.fileURL])
host.translatesAutoresizingMaskIntoConstraints = false;addSubview(host)
NSLayoutConstraint.activate([host.leadingAnchor.constraint(equalTo:leadingAnchor),host.trailingAnchor.constraint(equalTo:trailingAnchor),host.topAnchor.constraint(equalTo:topAnchor),host.bottomAnchor.constraint(equalTo:bottomAnchor)])
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func accepts(_ pasteboard: NSPasteboard) -> Bool {
if let song = DJPasteboardPayload.song(pasteboard) { return validSong?(song) == true }
return !DJPasteboardPayload.files(pasteboard).isEmpty
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
let allowed = accepts(sender.draggingPasteboard)
layer?.borderWidth = allowed ? 2 : 0;layer?.borderColor = tint.cgColor
return allowed ? .copy : []
}
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { accepts(sender.draggingPasteboard) ? .copy : [] }
override func draggingExited(_ sender: NSDraggingInfo?) { layer?.borderWidth = 0 }
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { accepts(sender.draggingPasteboard) }
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
layer?.borderWidth = 0
if let song = DJPasteboardPayload.song(sender.draggingPasteboard),validSong?(song) == true {
let receive = acceptSong
// Finish the native dragging session before a load rebuilds any SwiftUI rows.
DispatchQueue.main.async { receive?(song) };return true
}
let files = DJPasteboardPayload.files(sender.draggingPasteboard)
guard !files.isEmpty else { return false }
let receive = acceptFiles;DispatchQueue.main.async { receive?(files) };return true
}
override func concludeDragOperation(_ sender: NSDraggingInfo?) { layer?.borderWidth = 0 }
}
// NSHostingView participates in AppKit drag destination lookup itself. Forward
// its callbacks so embedded SwiftUI content cannot shadow the deck destination.
final class DJDropHostingView: NSHostingView<AnyView> {
weak var dropTarget: DJNativeDropView?
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { dropTarget?.draggingEntered(sender) ?? [] }
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { dropTarget?.draggingUpdated(sender) ?? [] }
override func draggingExited(_ sender: NSDraggingInfo?) { dropTarget?.draggingExited(sender) }
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { dropTarget?.prepareForDragOperation(sender) ?? false }
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { dropTarget?.performDragOperation(sender) ?? false }
override func concludeDragOperation(_ sender: NSDraggingInfo?) { dropTarget?.concludeDragOperation(sender) }
}