The body editor yields a paste it cannot read — the screenshot's ⌘V reaches the card window's attachment branch

The editor holds the keyboard from the moment the card window opens, and
NSTextView answers an image-only pasteboard with a disabled Paste row —
so the window's image branch sat one responder below the keyboard and
could never be reached by it. The yield is by capability
(readablePasteboardTypes), the same shape as the editor's existing
refusal of file drops: any text flavor keeps the paste in the editor,
and a pasteboard the editor has no reading of passes paste: to the
responder behind it. Live-probed with real window-server ⌘V chords in
both windows.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 06:28:02 -04:00
parent 297f12fa61
commit 242d013d8c
3 changed files with 145 additions and 1 deletions
+56
View File
@@ -405,4 +405,60 @@ final class CardBodyTextView: NSTextView {
]
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
}
// MARK: The paste yield
/// **A pasteboard this editor cannot read is never the editor's either** the drop rule above,
/// arrived at the keyboard (04-interactions.md Clipboard, the image-data branch; 05
/// Attachments makes the *window* answer V with an attachment import).
///
/// The focused-editor rule says a focused text surface wins V, and it still does: any pasteboard
/// carrying something this view can take text, foremost pastes into the text exactly as
/// before, image flavors riding beside it or not. But this view holds the keyboard from the
/// moment the window opens, and a *screenshot* pasteboard (image data, no text) is one it cannot
/// read at all: `NSTextView`'s own answer is a disabled menu row, which here means the window's
/// image branch sits one responder below the keyboard and can never be reached by it. "Wins"
/// must not mean "blocks what it cannot take" so a paste this editor has no reading of is
/// passed to the responder behind it, and the standard validation walks the same path.
///
/// The yield is by *capability*, not by content kind: `readablePasteboardTypes` is AppKit's own
/// statement of what this view would accept, so the expression cannot drift from the paste it
/// guards. In Preview the view is not editable and takes no paste, so there the window's branch
/// simply owns V outright.
/// The pasteboard the yield reads the general one in the app; tests hand in their own so the
/// suite never touches the machine's (`FakePasteboard`'s reason, one seam over).
var yieldPasteboard: NSPasteboard = .general
/// Whether this editor itself would take the current pasteboard.
private var takesPasteboardAsText: Bool {
isEditable && yieldPasteboard.availableType(from: readablePasteboardTypes) != nil
}
/// The responder behind this view that answers `paste:` the card window's image branch when it
/// is armed (`cardWindowImagePaste`; SwiftUI's bridge responds only while the handler is
/// attached), and `nil` when nothing behind would take the paste either.
private var pasteYieldTarget: NSResponder? {
var responder = nextResponder
while let current = responder {
if current.responds(to: #selector(NSText.paste(_:))) { return current }
responder = current.nextResponder
}
return nil
}
override func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool {
if item.action == #selector(NSText.paste(_:)), !takesPasteboardAsText {
return pasteYieldTarget != nil
}
return super.validateUserInterfaceItem(item)
}
override func paste(_ sender: Any?) {
guard !takesPasteboardAsText, let target = pasteYieldTarget else {
super.paste(sender)
return
}
target.tryToPerform(#selector(NSText.paste(_:)), with: sender)
}
}