eleven / ocho Public

Mac: whole-window drop zone + Messages-style staged attachments (#1012)

* mac: whole-window drop zone + Messages-style staged attachments

Drops now land anywhere on the window: one AppKit drag catcher above every
view (the SwiftUI .onDrop region lost to the composer's NSTextView and parked
panes' WKWebViews wherever they overlapped it), with a dashed drop hint while
a file is over the window. A file dropped on the composer stages instead of
pasting its path. Staged attachments render inside the input pill above the
text, like Messages — one pill, one message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: drop the drag-over hint overlay — the + badge is affordance enough

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 19b8e746df9db90ffa689f887b999952623f943e parent 7b58369
3 files changed, +321 −194
  • ios/LChatMac/MacComposerField.swift +40 −0
  • ios/LChatMac/NativeFeedView.swift +196 −178
  • ios/LChatMac/RootView.swift +85 −16
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift
index 0042db7..9f985a6 100644
--- a/ios/LChatMac/MacComposerField.swift
+++ b/ios/LChatMac/MacComposerField.swift
@@ -46,6 +46,11 @@ struct MacComposerField: NSViewRepresentable {
// Stage images / file URLs from a paste; returns whether anything staged.
// nil in a default-command thread (text-only surface, like the web).
var onStagePaste: ((NSPasteboard) -> Bool)? = nil
+ // Stage a file/image DRAG dropped on the field; returns whether anything
+ // staged. The window-wide drop zone normally claims the drag before it
+ // reaches the text view — this is the backstop that keeps NSTextView's
+ // default (insert the file's PATH as text) from ever being the outcome.
+ var onStageDrop: ((NSPasteboard) -> Bool)? = nil
func makeCoordinator() -> Coordinator { Coordinator(self) }
@@ -113,6 +118,7 @@ struct MacComposerField: NSViewRepresentable {
guard let tv = scroll.documentView as? ComposerTextView else { return }
controller?.view = tv
tv.onStagePaste = onStagePaste
+ tv.onStageDrop = onStageDrop
tv.isEditable = !isDisabled
tv.formatAsYouType = formatAsYouType
if tv.baseFontSize != fontSize {
@@ -308,6 +314,7 @@ final class MacComposerController {
final class ComposerTextView: NSTextView {
let placeholderLabel = NSTextField(labelWithString: "")
var onStagePaste: ((NSPasteboard) -> Bool)?
+ var onStageDrop: ((NSPasteboard) -> Bool)?
var onFocusChange: ((Bool) -> Void)?
// Format-as-you-type bookkeeping (armed in shouldChangeText, consumed in
// textDidChange; the stamp drives Backspace-restores-the-literal).
@@ -565,4 +572,37 @@ final class ComposerTextView: NSTextView {
}
if !staged { super.pasteAsPlainText(sender) }
}
+
+ // Drags: a file/image drop on the field stages an attachment — in a chat
+ // composer that is always what the drop means; NSTextView's default
+ // (insert the file's PATH as text) never is. Text drags keep the standard
+ // behaviour. The window-wide drop zone usually claims the drag before it
+ // gets here; this backstop covers any path that still resolves to the
+ // text view.
+ private func stageableDrag(_ sender: NSDraggingInfo) -> Bool {
+ guard onStageDrop != nil else { return false }
+ let pb = sender.draggingPasteboard
+ if pb.canReadObject(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) { return true }
+ if pb.canReadObject(forClasses: [NSFilePromiseReceiver.self], options: nil) { return true }
+ return pb.availableType(from: [.png, .tiff]) != nil
+ }
+
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
+ stageableDrag(sender) ? .copy : super.draggingEntered(sender)
+ }
+
+ override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
+ stageableDrag(sender) ? .copy : super.draggingUpdated(sender)
+ }
+
+ override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool {
+ stageableDrag(sender) ? true : super.prepareForDragOperation(sender)
+ }
+
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
+ if stageableDrag(sender), let onStageDrop {
+ return onStageDrop(sender.draggingPasteboard)
+ }
+ return super.performDragOperation(sender)
+ }
}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 3e32b61..5bfccd9 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -3,7 +3,8 @@ import SwiftUI
import AVFoundation
import os
-// Routes drop-to-attach from RootView's ONE drop region to the visible feed.
+// Routes drop-to-attach from RootView's ONE drop surface (the window-wide
+// AppKit catcher, with its SwiftUI .onDrop safety net) to the visible feed.
// Per-pane .onDrop regions can't coexist with the keep-alive ZStack: drop
// targeting resolves to the topmost region regardless of opacity or
// allowsHitTesting, so a hidden space's region either swallowed the drop
@@ -18,19 +19,23 @@ import os
// return to an earlier space). Mount lifecycle is structural and can't be
// coalesced away; selection is read live, so there is no event ordering to
// get wrong. Main-thread only (registration comes from view lifecycle,
-// delivery from the drop delegate).
+// delivery from the drop surface).
+//
+// The payload is the drag PASTEBOARD, not [NSItemProvider]: the AppKit
+// catcher only ever has the pasteboard, and it carries every drag shape the
+// feed stages (file URLs, file promises, raw image data).
final class PaneDropRouter {
static let shared = PaneDropRouter()
private struct Entry {
let token: UUID
- let handler: ([NSItemProvider]) -> Void
+ let handler: (NSPasteboard) -> Void
}
// Keyed by the pane's instance origin — hidden panes stay registered
// (they stay mounted); selection picks between them at drop time.
private var entries: [String: Entry] = [:]
func register(_ key: String, token: UUID,
- handler: @escaping ([NSItemProvider]) -> Void) {
+ handler: @escaping (NSPasteboard) -> Void) {
Logger.identity.info("drop router: register \(key, privacy: .public)")
entries[key] = Entry(token: token, handler: handler)
}
@@ -41,19 +46,19 @@ final class PaneDropRouter {
Logger.identity.info("drop router: unregister \(key, privacy: .public)")
entries[key] = nil
}
- // Drives the drag's + badge via validateDrop: no registered feed for the
- // selected pane (locked pane, default-command thread, first-run) → the
- // detail area rejects the drag, matching the old empty-type-list gate.
+ // Drives the drag's + badge: no registered feed for the selected pane
+ // (locked pane, default-command thread, first-run) → the window rejects
+ // the drag, matching the old empty-type-list gate.
func canDrop(over key: String?) -> Bool {
key.flatMap { entries[$0] } != nil
}
- func deliver(_ providers: [NSItemProvider], over key: String?) -> Bool {
+ func deliver(_ pasteboard: NSPasteboard, over key: String?) -> Bool {
guard let key, let entry = entries[key] else {
Logger.identity.error("drop router: no feed for \(key ?? "<no selection>", privacy: .public) — drop bounced")
return false
}
- Logger.identity.info("drop router: deliver \(providers.count) provider(s) to \(key, privacy: .public)")
- entry.handler(providers)
+ Logger.identity.info("drop router: deliver to \(key, privacy: .public)")
+ entry.handler(pasteboard)
return true
}
}
@@ -436,6 +441,15 @@ struct NativeFeedView: View {
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
}
}
+ // Staged attachments live INSIDE the input pill, so staging and
+ // unstaging resize the composer inset — keep the newest message
+ // pinned through it when the reader was at the bottom.
+ .onChange(of: staged.isEmpty) { _, _ in
+ if let last = model.messages.last?.id,
+ let f = frames.rows[last], f.minY < frames.viewport.height + 150 {
+ pinUntilSettled(proxy)
+ }
+ }
// Opening a chat lands at the newest message — and STAYS there:
// one pin isn't enough, because rows materialise with their real
// (often taller) heights after the jump and shift the content a
@@ -515,18 +529,6 @@ struct NativeFeedView: View {
.transition(.opacity.combined(with: .offset(y: 12)))
}
}
- // Staged previews FLOAT over the feed, hugging the composer's top edge
- // (this overlay is applied BEFORE the inset, so its bottom IS the
- // composer's top) — in the inset they grew it and shoved the feed up.
- .overlay(alignment: .bottomLeading) {
- if !staged.isEmpty {
- // Forced to the leading edge (the chip row's own 10pt leading
- // then lines it up with the + button, as on the web).
- stagedChips
- .padding(.bottom, 8)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
.safeAreaInset(edge: .bottom) {
// An ended DM (#578): the server refuses new messages, so the
// composer gives way to the closed banner — the one honest action
@@ -601,27 +603,19 @@ struct NativeFeedView: View {
// topmost region regardless of opacity/allowsHitTesting — a hidden
// pane's region either stole the drop (staging into the wrong space's
// composer) or, gated, dead-ended the drag with no + badge at all.
- // Instead RootView holds the ONE drop region for the whole detail
- // area and PaneDropRouter (top of file) hands each drop to whichever
+ // Instead RootView owns the ONE drop surface for the whole window
+ // and PaneDropRouter (top of file) hands each drop to whichever
// registered feed belongs to the pane selected at drag time.
}
- // What the feed accepts on a drop: real files, promised files (how most
- // apps vend drags — screenshots, Photos), and raw image/movie data.
- static let droppableTypes: [UTType] = {
- var types: [UTType] = [.fileURL, .image, .movie]
- types += NSFilePromiseReceiver.readableDraggedTypes.compactMap { UTType($0) }
- return types
- }()
-
// Register this feed as its pane's drop receiver for as long as it is
- // mounted — visibility is the delegate's problem, not ours. A
+ // mounted — visibility is the drop surface's problem, not ours. A
// default-command thread (text-only surface) or an instance-less mount
// (the Debug diff pane) registers nothing, so the drag shows no + badge
// there — same behaviour the per-feed type-list gate used to give.
private func registerForDrops() {
guard defaultCommand == nil, let key = instance?.origin else { return }
- PaneDropRouter.shared.register(key, token: dropToken) { stageProviders($0) }
+ PaneDropRouter.shared.register(key, token: dropToken) { stageDrop($0) }
}
static let bottomSentinel = "bottom-sentinel"
@@ -1189,9 +1183,10 @@ struct NativeFeedView: View {
composerFocused = true
}
- // The staged attachments above the field, web-style: images show a real
- // rounded thumbnail with the ✕ overlapping its corner and the upload ring
- // over the middle; files keep a pill. Left-aligned with the + button.
+ // The staged attachments, Messages-style: INSIDE the input pill, above
+ // the text, so the attachment and its caption visibly travel as one
+ // message. Images show a real rounded thumbnail with the ✕ overlapping
+ // its corner and the upload ring over the middle; files keep a pill.
private var stagedChips: some View {
HStack(alignment: .top, spacing: 8) {
ForEach(staged) { s in
@@ -1202,9 +1197,8 @@ struct NativeFeedView: View {
}
}
}
- .padding(.leading, 10) // flush with the + button's left edge, like the web
- .padding(.trailing, 12)
- .padding(.top, 8)
+ .padding(.horizontal, 12)
+ .padding(.top, 10)
}
private func imageChip(_ s: Staged, preview: PlatformImage) -> some View {
@@ -1212,7 +1206,7 @@ struct NativeFeedView: View {
Image(platform: preview)
.resizable()
.scaledToFill()
- .frame(width: 64, height: 64)
+ .frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
if s.attachment == nil {
uploadRing(s.progress)
@@ -1295,112 +1289,123 @@ struct NativeFeedView: View {
.buttonStyle(.plain)
.padding(.bottom, 4)
}
- // The NSTextView-backed field (MacComposerField): a real caret —
- // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819),
- // caret-preserving splices — and the base the rich stages of #890
- // build on. Plain text at this stage. Typeahead keys route through
- // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
- // falls through to send when no menu is up; ↑ on an empty draft
- // edits the last message.
- MacComposerField(
- text: $draft,
- focused: $composerFocused,
- height: $composerHeight,
- caret: $composerCaret,
- placeholder: defaultCommand?.placeholder ?? "Message",
- fontSize: chatFontSize, // the feed's reading size, like the web composer
- // Queued behind an upload = the message is committed; editing
- // what's about to auto-send would race it.
- isDisabled: sendQueued,
- formatAsYouType: formatAsYouType,
- controller: composerController,
- onSend: sendDraft,
- onMoveUp: {
- if !taItems.isEmpty {
- taIndex = (taIndex + taItems.count - 1) % taItems.count
- return true
- }
- if draft.isEmpty, editingID == nil,
- let last = model.messages.last(where: { $0.mine && !$0.action }) {
- startEdit(last)
- return true
- }
- return false
- },
- onMoveDown: {
- guard !taItems.isEmpty else { return false }
- taIndex = (taIndex + 1) % taItems.count
- return true
- },
- onAccept: {
- guard !taItems.isEmpty, let token = taToken else { return false }
- pick(taItems[taIndex], token: token)
- return true
- },
- onEscape: {
- if !taItems.isEmpty {
- taToken = nil
- taItems = []
+ // ONE pill around attachments and text, the Messages read: what's
+ // staged and what's typed are visibly the same message.
+ VStack(alignment: .leading, spacing: 0) {
+ if !staged.isEmpty {
+ stagedChips
+ }
+ // The NSTextView-backed field (MacComposerField): a real caret —
+ // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819),
+ // caret-preserving splices — and the base the rich stages of #890
+ // build on. Plain text at this stage. Typeahead keys route through
+ // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
+ // falls through to send when no menu is up; ↑ on an empty draft
+ // edits the last message.
+ MacComposerField(
+ text: $draft,
+ focused: $composerFocused,
+ height: $composerHeight,
+ caret: $composerCaret,
+ placeholder: defaultCommand?.placeholder ?? "Message",
+ fontSize: chatFontSize, // the feed's reading size, like the web composer
+ // Queued behind an upload = the message is committed; editing
+ // what's about to auto-send would race it.
+ isDisabled: sendQueued,
+ formatAsYouType: formatAsYouType,
+ controller: composerController,
+ onSend: sendDraft,
+ onMoveUp: {
+ if !taItems.isEmpty {
+ taIndex = (taIndex + taItems.count - 1) % taItems.count
+ return true
+ }
+ if draft.isEmpty, editingID == nil,
+ let last = model.messages.last(where: { $0.mine && !$0.action }) {
+ startEdit(last)
+ return true
+ }
+ return false
+ },
+ onMoveDown: {
+ guard !taItems.isEmpty else { return false }
+ taIndex = (taIndex + 1) % taItems.count
return true
- }
- // An open command panel is the most recently summoned
- // layer — Escape peels it before reply/edit below (the
- // page posts {panel: null} back, which collapses the
- // frame, so the panel's onClose hook always runs).
- if panelVisible {
- commandHost?.dismissPanel()
+ },
+ onAccept: {
+ guard !taItems.isEmpty, let token = taToken else { return false }
+ pick(taItems[taIndex], token: token)
return true
+ },
+ onEscape: {
+ if !taItems.isEmpty {
+ taToken = nil
+ taItems = []
+ return true
+ }
+ // An open command panel is the most recently summoned
+ // layer — Escape peels it before reply/edit below (the
+ // page posts {panel: null} back, which collapses the
+ // frame, so the panel's onClose hook always runs).
+ if panelVisible {
+ commandHost?.dismissPanel()
+ return true
+ }
+ return false
+ },
+ // Paste an image (or a copied file) straight onto the draft —
+ // it stages as an upload, like the web composer's paste.
+ // Absent in a default-command thread (text-only surface), so a
+ // text paste there stays a plain text paste.
+ onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil,
+ // A file DRAG landing on the field stages too — the backstop
+ // behind the window-wide drop zone, so NSTextView's default
+ // (paste the file's PATH as text) is never the outcome.
+ onStageDrop: defaultCommand == nil ? { stageDrop($0) } : nil
+ )
+ // The field's height is pushed by the field itself (measured
+ // 1–9 lines) — SwiftUI never re-asks a representable when only
+ // its content changes.
+ .frame(height: composerHeight)
+ // Claim the row's full width (the + and send buttons keep
+ // their fixed sizes) — without this the HStack centres a
+ // short pill around the representable's measured width.
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ // Regaining the app drops the caret into the message box, the
+ // same courtesy the webview shell extends (focusComposer) —
+ // but ONLY from the pane actually on screen (every instance's
+ // pane stays mounted at opacity 0, and their writes were
+ // stealing focus from the visible composer), and never over
+ // an open ⌘K palette (its own handler re-asserts its field).
+ .onReceive(NotificationCenter.default.publisher(
+ for: NSApplication.didBecomeActiveNotification)) { _ in
+ guard isActive, !app.showPalette else { return }
+ DispatchQueue.main.async {
+ if !app.showPalette { composerFocused = true }
+ }
}
- return false
- },
- // Paste an image (or a copied file) straight onto the draft —
- // it stages as an upload, like the web composer's paste.
- // Absent in a default-command thread (text-only surface), so a
- // text paste there stays a plain text paste.
- onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil
- )
- // The field's height is pushed by the field itself (measured
- // 1–9 lines) — SwiftUI never re-asks a representable when only
- // its content changes.
- .frame(height: composerHeight)
- // Claim the row's full width (the + and send buttons keep
- // their fixed sizes) — without this the HStack centres a
- // short pill around the representable's measured width.
- .frame(maxWidth: .infinity)
- .padding(.horizontal, 14)
- .padding(.vertical, 9)
- // The web composer: a bordered pill on the panel background,
- // not a filled grey field.
- .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .fill(.background))
- .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .strokeBorder(.quaternary, lineWidth: 1))
- // Regaining the app drops the caret into the message box, the
- // same courtesy the webview shell extends (focusComposer) —
- // but ONLY from the pane actually on screen (every instance's
- // pane stays mounted at opacity 0, and their writes were
- // stealing focus from the visible composer), and never over
- // an open ⌘K palette (its own handler re-asserts its field).
- .onReceive(NotificationCenter.default.publisher(
- for: NSApplication.didBecomeActiveNotification)) { _ in
- guard isActive, !app.showPalette else { return }
- DispatchQueue.main.async {
- if !app.showPalette { composerFocused = true }
+ // Focus on open — asserted again a beat later AND when the
+ // feed reveals: a FocusState write in onAppear races the
+ // freshly-inserted (still invisible) field and loses.
+ .onAppear {
+ guard isActive else { return }
+ composerFocused = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ if isActive { composerFocused = true }
+ }
}
- }
- // Focus on open — asserted again a beat later AND when the
- // feed reveals: a FocusState write in onAppear races the
- // freshly-inserted (still invisible) field and loses.
- .onAppear {
- guard isActive else { return }
- composerFocused = true
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
- if isActive { composerFocused = true }
+ .onChange(of: settled) { _, isSettled in
+ if isSettled, isActive { composerFocused = true }
}
- }
- .onChange(of: settled) { _, isSettled in
- if isSettled, isActive { composerFocused = true }
- }
+ }
+ // The web composer: a bordered pill on the panel background,
+ // not a filled grey field.
+ .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .fill(.background))
+ .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
Button(action: sendDraft) {
if sendQueued {
// Queued behind an in-flight upload — the web's .send-waiting.
@@ -1845,8 +1850,8 @@ struct NativeFeedView: View {
// Pasted content (from MacComposerField's paste override): file URLs stage
// directly; raw image data stages as a PNG (like the web composer's
// paste-to-upload). Returns whether anything staged, so the field knows
- // the paste was (partly) consumed. Drops keep their own provider pipeline
- // (stageProviders); the pasteboard hands us its contents directly.
+ // the paste was (partly) consumed. Drops ride their own pipeline
+ // (stageDrop) — a drag can also carry file PROMISES, which a paste never does.
private func stagePasteboard(_ pb: NSPasteboard) -> Bool {
if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] {
let files = urls.filter(\.isFileURL)
@@ -1863,45 +1868,58 @@ struct NativeFeedView: View {
return false
}
- // ONE pipeline for every provider source (paste, drop): a real file URL
- // stages directly; anything else that reads as an image or movie is
- // materialised via loadFileRepresentation — which works whether the app
- // vended raw data or a file promise (CleanShot, Photos: the provider
- // fulfils the promise into the temp file it hands us). The temp URL dies
- // when the closure returns, so the bytes are read inside it.
- private func stageProviders(_ providers: [NSItemProvider]) {
- for provider in providers {
- // Verification tap (names, never content) — drags arrive in wildly
- // different shapes per source app; this is how the next "my drag
- // doesn't work" gets diagnosed without an instrumented build.
- Logger.identity.info("drop provider types: \(provider.registeredTypeIdentifiers.joined(separator: ", "), privacy: .public) suggested: \(provider.suggestedName ?? "-", privacy: .public)")
- if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
- _ = provider.loadObject(ofClass: URL.self) { url, _ in
- if let url { DispatchQueue.main.async { stage(url) } }
- }
- continue
- }
- let types: [UTType] = [.png, .jpeg, .tiff, .heic, .image, .movie]
- guard let type = types.first(where: {
- provider.hasItemConformingToTypeIdentifier($0.identifier)
- }) else { continue }
- _ = provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, error in
- if let error {
- Logger.identity.error("drop fileRep failed: \(String(describing: error), privacy: .public)")
+ // ONE pipeline for every AppKit drag (the window drop zone, the composer
+ // field's backstop): real file URLs stage directly; file promises — how
+ // most apps vend drags (CleanShot, Photos, browsers) — are received into
+ // a temp folder and staged from there; raw image data stages as a file.
+ // URLs win over promises when a drag carries both, so one item never
+ // stages twice.
+ @discardableResult
+ private func stageDrop(_ pb: NSPasteboard) -> Bool {
+ // Verification tap (types, never content) — drags arrive in wildly
+ // different shapes per source app; this is how the next "my drag
+ // doesn't work" gets diagnosed without an instrumented build.
+ Logger.identity.info("drop pasteboard types: \((pb.types ?? []).map(\.rawValue).joined(separator: ", "), privacy: .public)")
+ if let urls = pb.readObjects(forClasses: [NSURL.self],
+ options: [.urlReadingFileURLsOnly: true]) as? [URL],
+ !urls.isEmpty {
+ urls.forEach { stage($0) }
+ return true
+ }
+ if let receivers = pb.readObjects(forClasses: [NSFilePromiseReceiver.self]) as? [NSFilePromiseReceiver],
+ !receivers.isEmpty {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("drop-" + UUID().uuidString, isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ for receiver in receivers {
+ receiver.receivePromisedFiles(atDestination: dir, options: [:],
+ operationQueue: Self.promiseQueue) { url, error in
+ if let error {
+ Logger.identity.error("drop promise failed: \(String(describing: error), privacy: .public)")
+ return
+ }
+ DispatchQueue.main.async { stage(url) }
}
- guard let url, let data = try? Data(contentsOf: url) else { return }
- // Prefer the drag's human name; the temp file supplies the
- // extension (its name is machine-generated but correctly typed).
- let ext = url.pathExtension
- let base = provider.suggestedName ?? url.deletingPathExtension().lastPathComponent
- let name = ext.isEmpty || base.lowercased().hasSuffix("." + ext.lowercased())
- ? base : base + "." + ext
- let staged = UTType(filenameExtension: url.pathExtension) ?? type
- DispatchQueue.main.async { stage(data: data, name: name, type: staged) }
}
+ return true
+ }
+ for type in [NSPasteboard.PasteboardType.png, .tiff] {
+ guard let data = pb.data(forType: type) else { continue }
+ stage(data: data, name: type == .png ? "dropped.png" : "dropped.tiff",
+ type: type == .png ? .png : .tiff)
+ return true
}
+ return false
}
+ // Promise fulfilment happens off-main (the source app writes the file);
+ // staging hops back to main.
+ private static let promiseQueue: OperationQueue = {
+ let q = OperationQueue()
+ q.qualityOfService = .userInitiated
+ return q
+ }()
+
private func stage(_ url: URL) {
guard let data = try? Data(contentsOf: url) else { return }
let name = url.lastPathComponent
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index 70ad932..3dc8fbc 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -1,5 +1,6 @@
import LChatCore
import SwiftUI
+import UniformTypeIdentifiers
// Native layout: a fixed server rail on the left, the selected instance's
// NATIVE chat surface on the right. A plain HStack (not NavigationSplitView)
@@ -116,6 +117,21 @@ struct RootView: View {
YouTubeLightboxOverlay(item: item) { app.youtubeLightbox = nil }
}
}
+ // Drop-to-attach for the WHOLE window. An AppKit catcher, not a
+ // SwiftUI .onDrop: AppKit hands a drag to the deepest REGISTERED view
+ // under the cursor, so the composer's NSTextView (whose default drop
+ // pastes the file's PATH as text) and any parked pane's WKWebView beat
+ // the hosting view's region wherever they overlapped it — the "drops
+ // only catch in a strip above the composer" bug. Topmost in the view
+ // tree, this catcher wins everywhere; clicks fall straight through it.
+ // No hover overlay — the drag's + badge is affordance enough.
+ .overlay {
+ WindowDropZone(
+ // The create takeover owns the window; a drop would stage
+ // invisibly into the pane underneath it.
+ canDrop: { createModel == nil && PaneDropRouter.shared.canDrop(over: selectedPaneKey(app)) },
+ perform: { PaneDropRouter.shared.deliver($0, over: selectedPaneKey(app)) })
+ }
}
private func nativeInstance(for chat: SavedChat) -> SavedInstance? {
@@ -190,35 +206,88 @@ struct RootView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if let s = app.selection { activated.insert(s) } }
.onChange(of: app.selection) { _, s in if let s { activated.insert(s) } }
- // THE drop-to-attach region — one for the whole detail area, delivered
- // to the registered feed of the pane selected at drag time. Never
- // per-pane: the keep-alive ZStack means overlapping regions, and drop
- // targeting picks the topmost one regardless of opacity or
- // allowsHitTesting, so a hidden space's region hijacks or dead-ends
- // drops meant for the visible one.
- .onDrop(of: NativeFeedView.droppableTypes, delegate: PaneDropDelegate(app: app))
+ // SAFETY NET only — the window-wide AppKit catcher (WindowDropZone,
+ // end of body) claims drags first everywhere it is consulted; this
+ // SwiftUI region keeps the pre-catcher behaviour alive for any drag
+ // path that never reaches it. Still ONE region, never per-pane: the
+ // keep-alive ZStack means overlapping regions, and drop targeting
+ // picks the topmost one regardless of opacity or allowsHitTesting.
+ .onDrop(of: paneDroppableTypes, delegate: PaneDropDelegate(app: app))
}
}
-// Resolves the drop's pane at DRAG time, from the live selection — never from
+// The drop's pane, resolved at DRAG time from the live selection — never from
// view-lifecycle events (an isActive-flip claim scheme lost the owner to a
// coalesced onChange; see PaneDropRouter). The key mirrors nativeInstance(for:):
// a chat routes to the feed registered under its instance's origin.
+private func selectedPaneKey(_ app: MacAppState) -> String? {
+ guard let sel = app.selection,
+ let chat = app.chats.first(where: { $0.id == sel }) else { return nil }
+ return chat.origin?.absoluteString
+}
+
+// What drop-to-attach accepts: real files, promised files (how most apps vend
+// drags — screenshots, Photos), and raw image/movie data.
+private let paneDroppableTypes: [UTType] = {
+ var types: [UTType] = [.fileURL, .image, .movie]
+ types += NSFilePromiseReceiver.readableDraggedTypes.compactMap { UTType($0) }
+ return types
+}()
+
private struct PaneDropDelegate: DropDelegate {
let app: MacAppState
- private var selectedPaneKey: String? {
- guard let sel = app.selection,
- let chat = app.chats.first(where: { $0.id == sel }) else { return nil }
- return chat.origin?.absoluteString
- }
// Consulted per drag session (unlike the registered type list), so the
// + badge tracks the live selection: no registered feed → the drag bounces.
func validateDrop(info: DropInfo) -> Bool {
- PaneDropRouter.shared.canDrop(over: selectedPaneKey)
+ PaneDropRouter.shared.canDrop(over: selectedPaneKey(app))
}
func performDrop(info: DropInfo) -> Bool {
- PaneDropRouter.shared.deliver(info.itemProviders(for: NativeFeedView.droppableTypes),
- over: selectedPaneKey)
+ // The drag pasteboard directly (DropInfo never exposes it) — the same
+ // payload the catcher delivers, so the router speaks one language.
+ PaneDropRouter.shared.deliver(NSPasteboard(name: .drag), over: selectedPaneKey(app))
+ }
+}
+
+// The invisible whole-window drag destination. Its NSView registers for the
+// droppable types and, sitting above every other view, is what AppKit's
+// deepest-registered-view search finds first — the NSTextView and WKWebView
+// interceptors underneath never see the drag. hitTest returns nil so ordinary
+// mouse events keep falling through to the real UI (drag-destination search
+// walks registered frames, not the hitTest override).
+private struct WindowDropZone: NSViewRepresentable {
+ var canDrop: () -> Bool
+ var perform: (NSPasteboard) -> Bool
+
+ func makeNSView(context: Context) -> DropCatcherView { DropCatcherView() }
+
+ func updateNSView(_ view: DropCatcherView, context: Context) {
+ view.canDrop = canDrop
+ view.perform = perform
+ }
+}
+
+private final class DropCatcherView: NSView {
+ var canDrop: () -> Bool = { false }
+ var perform: (NSPasteboard) -> Bool = { _ in false }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ var types: [NSPasteboard.PasteboardType] = [.fileURL, .png, .tiff]
+ types += NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) }
+ registerForDraggedTypes(types)
+ }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
+
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
+ // A drag that STARTED here (an image pulled out of a bubble, a rail
+ // reorder) is leaving, not arriving — never a drop-to-attach.
+ guard sender.draggingSource == nil, canDrop() else { return [] }
+ return .copy
+ }
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
+ perform(sender.draggingPasteboard)
}
}