53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
4.9 KiB
Swift
125 lines
4.9 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - NSView drag destination
|
|
// Wired at the AppKit level in IslandWindowController (not via SwiftUI NSViewRepresentable)
|
|
// so it never interferes with SwiftUI hit-testing.
|
|
|
|
final class FileDropNSView: NSView {
|
|
var onDragEntered: ((CGPoint) -> Void)?
|
|
var onDragUpdated: ((CGPoint) -> Void)?
|
|
var onDragExited: (() -> Void)?
|
|
var onFilesDropped: (([URL]) -> Void)?
|
|
|
|
override init(frame: NSRect) {
|
|
super.init(frame: frame)
|
|
registerForDraggedTypes([.fileURL])
|
|
}
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
// Pass all mouse events through — drag-drop uses NSDraggingDestination, not hitTest
|
|
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
|
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
|
onDragEntered?(sender.draggingLocation)
|
|
return .copy
|
|
}
|
|
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
|
|
onDragUpdated?(sender.draggingLocation)
|
|
return .copy
|
|
}
|
|
override func draggingExited(_ sender: NSDraggingInfo?) { onDragExited?() }
|
|
|
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
|
guard let urls = sender.draggingPasteboard.readObjects(
|
|
forClasses: [NSURL.self],
|
|
options: [.urlReadingFileURLsOnly: true]
|
|
) as? [URL], !urls.isEmpty else { return false }
|
|
onFilesDropped?(urls)
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - File drop handler
|
|
|
|
enum FileDropHandler {
|
|
@MainActor
|
|
static func handle(urls: [URL], state: AppState) async {
|
|
guard let url = urls.first else { return }
|
|
let name = url.lastPathComponent
|
|
|
|
// Start animation immediately — do NOT block on file copy.
|
|
// Use original URL first; swap to inbox copy once background copy finishes.
|
|
state.droppedFile = DroppedFile(url: url, name: name)
|
|
state.uploadProgress = 0
|
|
state.fileDragOver = false
|
|
state.promptContext = .file(name: name, fileURL: url)
|
|
|
|
let dur = 2.4
|
|
UploadSequenceEngine.shared.performDrop(uploadDuration: dur)
|
|
|
|
// Copy to inbox in background — update state when done
|
|
let inbox = HookServer.supportDir.appendingPathComponent("inbox")
|
|
Task.detached {
|
|
try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
|
|
let dest = inbox.appendingPathComponent(name)
|
|
try? FileManager.default.removeItem(at: dest)
|
|
if (try? FileManager.default.copyItem(at: url, to: dest)) != nil {
|
|
await MainActor.run {
|
|
state.droppedFile = DroppedFile(url: dest, name: name)
|
|
state.promptContext = .file(name: name, fileURL: dest)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drop feedback
|
|
NotificationCenter.default.post(name: .botGulp, object: nil)
|
|
SoundEngine.shared.play("approve")
|
|
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
|
NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0))
|
|
|
|
state.uploadDuration = dur
|
|
state.uploadStartTime = Date()
|
|
state.view = .uploading // canvas stays active: uploadActive covers .uploading
|
|
|
|
// Canvas timeline from drop:
|
|
// T_DROP → T_PROG_START : ≈1.30s gulp + shrink + bar reveal
|
|
// T_PROG_START → progEnd: dur progress bar fills
|
|
// progEnd → growEnd : 0.70s Mochi grows back to choose position
|
|
let preProgress = USC.T_PROG_START - USC.T_DROP // ≈1.30s
|
|
|
|
// Tick sounds — delayed to sync with canvas progress start
|
|
Task { @MainActor in
|
|
var lastTens = 0
|
|
let progStart = Date().addingTimeInterval(preProgress)
|
|
while lastTens < 9 {
|
|
try? await Task.sleep(nanoseconds: 80_000_000)
|
|
let approxP = min(1.0, max(0, Date().timeIntervalSince(progStart) / dur))
|
|
let tens = Int(approxP * 10)
|
|
if tens > lastTens {
|
|
SoundEngine.shared.play("tick")
|
|
lastTens = tens
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for canvas progress to complete (gulp/shrink phase + upload duration)
|
|
try? await Task.sleep(nanoseconds: UInt64((preProgress + dur) * 1_000_000_000))
|
|
|
|
// Canvas shows checkmark at this point
|
|
SoundEngine.shared.play("approve")
|
|
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
|
|
|
// Wait for grow-back animation + choose overlay settle
|
|
try? await Task.sleep(nanoseconds: UInt64(1_000_000_000))
|
|
|
|
// Clean up upload state
|
|
state.uploadProgress = 0
|
|
state.uploadStartTime = nil
|
|
|
|
// Switch to choose — canvas stays active (uploadActive covers .choose).
|
|
// Engine deactivates when user clicks a canvas choose button or navigates away.
|
|
state.view = .choose
|
|
}
|
|
}
|