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:
@@ -59,7 +59,7 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim
|
||||
- Shows **every top-level file of `attachments/`** — including files also embedded in the body (settled: the section is the card's complete file inventory, no reference-tracking magic; an image appearing in both places is honest, not a bug). Subfolders are tolerated but not surfaced (01-storage-format.md's attachments rules).
|
||||
- **Compact rows**: small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename, one row per file. The section header carries a quiet add affordance; empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…, ⇧⌘A) — the drop surface remains the **whole window** (name collisions auto-rename, Finder-style — 01-storage-format.md). **Drop precedence is split by payload** (settled): file drops import as attachments anywhere in the window — Edit mode included, the text editor never intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as ordinary insertion, and is inert elsewhere in the window. **One carve-out by hover target** (ruled 2026-07-29 — comment attachments are authorable): a file dropped **within the comment composer's bounds** imports to the draft's `attachments/`, and within an **inline comment edit session's bounds** to that comment's — the window-wide card default covers everywhere else (The comments column below).
|
||||
- Row interactions: double-click or Return opens; context menu Open / Reveal in Finder / **Set as Hero** or **Remove Hero** / Remove (moves to the **system** Trash, never hard-deletes — 03-board-ui.md's naming constraint keeps this distinct from board deletion); rows drag out their file URL. **The hero row is one slot with two words** (ruled 2026-08-09, 03-board-ui.md § Card face ▸ Hero image): "Set as Hero" on any image row that is not already the hero, "Remove Hero" on the one that is, and **nothing at all** on a row that can be neither — present-or-absent rather than greyed, because a disabled "Set as Hero" on a `.zip` would claim a capability that will never arrive (Remove, just below it, greys under the lock as before). Removing the hero removes the *key*, never the file. The row that is the hero says so to VoiceOver as its value.
|
||||
- **⌘V pastes a picture onto this card** (04-interactions.md ▸ Clipboard's image-data branch): the whole-window drop's keyboard twin, landing in the same `attachments/` through the same import path. A focused text field — the body editor, the composer, an inline comment edit — consumes ⌘V natively and stays a text paste, which is the drop precedence's rule arriving on the other input.
|
||||
- **⌘V pastes a picture onto this card** (04-interactions.md ▸ Clipboard's image-data branch): the whole-window drop's keyboard twin, landing in the same `attachments/` through the same import path. A focused text field — the body editor, the composer, an inline comment edit — consumes ⌘V natively and stays a text paste, which is the drop precedence's rule arriving on the other input. **The body editor yields a paste it cannot read** (ruled 2026-08-09, live-probed): the editor holds the keyboard from the moment the window opens, so "a focused editor wins ⌘V" must not mean "blocks what it cannot take" — a pasteboard with no flavor the editor accepts (the screenshot's image-only pasteboard, foremost) passes `paste:` to the responder behind it, where the window's attachment branch answers; any text flavor riding along keeps the paste in the editor exactly as before. The yield is by capability (`readablePasteboardTypes`), the drop rule's own shape — the editor declines file *drops* the same way. The composer and inline comment edits do not yield yet; theirs is a narrower surface and a follow-up call.
|
||||
- **Keyboard-native, new in the rewrite** (the pathfinder's strip was pointer-only): the section is focusable; arrows move between rows, **Space QuickLooks** the selected row, Return opens it, ⌫ removes it (same system-Trash semantics).
|
||||
|
||||
### Style
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
@@ -769,3 +770,90 @@ struct PasteBoardBackgroundTests {
|
||||
== "Pasted Background.png")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The body editor's paste yield
|
||||
|
||||
/// The responder behind the editor in these tests — stands where the card window's
|
||||
/// `cardWindowImagePaste` bridge stands in the app, and only counts.
|
||||
@MainActor
|
||||
private final class PasteCatcher: NSView {
|
||||
var pastes = 0
|
||||
@objc func paste(_ sender: Any?) { pastes += 1 }
|
||||
}
|
||||
|
||||
/// **`CardBodyTextView` yields a paste it cannot read** (05-card-window.md ▸ Attachments, ruled
|
||||
/// 2026-08-09) — the screenshot pasteboard reaches the window's attachment branch even while the
|
||||
/// editor holds the keyboard, and a text paste never leaves the editor.
|
||||
///
|
||||
/// The pasteboard is a private named one through the view's `yieldPasteboard` seam, so the suite
|
||||
/// never reads the machine's — except through `super.paste`, which is AppKit's own and is exactly
|
||||
/// why the text-path test asserts the catcher stayed silent rather than what landed in the view.
|
||||
@MainActor
|
||||
@Suite("Paste yield ▸ the body editor")
|
||||
struct PasteYieldTests {
|
||||
|
||||
private func makeEditor(behind catcher: PasteCatcher? = nil) -> (CardBodyTextView, NSPasteboard) {
|
||||
let pasteboard = NSPasteboard(name: NSPasteboard.Name("test-yield-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
let editor = CardBodyTextView(frame: .zero)
|
||||
editor.isRichText = false
|
||||
editor.isEditable = true
|
||||
editor.yieldPasteboard = pasteboard
|
||||
catcher?.addSubview(editor)
|
||||
return (editor, pasteboard)
|
||||
}
|
||||
|
||||
private var pasteItem: NSMenuItem {
|
||||
NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "")
|
||||
}
|
||||
|
||||
@Test("An image-only pasteboard validates through the editor and forwards to the responder behind")
|
||||
func imageOnlyYields() {
|
||||
let catcher = PasteCatcher(frame: .zero)
|
||||
let (editor, pasteboard) = makeEditor(behind: catcher)
|
||||
defer { pasteboard.releaseGlobally() }
|
||||
pasteboard.setData(encodedImage(.png), forType: .png)
|
||||
|
||||
#expect(editor.validateUserInterfaceItem(pasteItem))
|
||||
editor.paste(nil)
|
||||
#expect(catcher.pastes == 1)
|
||||
}
|
||||
|
||||
/// No expectation on `validateUserInterfaceItem` here: a readable pasteboard routes validation
|
||||
/// to `super`, and `NSTextView`'s own answer reads the *machine's* general pasteboard — asserting
|
||||
/// it would tie the test to whatever the host's clipboard happens to hold. The claim under test
|
||||
/// is the routing: a text flavor means the paste is the editor's, so nothing is forwarded.
|
||||
@Test("A text flavor keeps the paste in the editor — riding image or not")
|
||||
func textStaysTheEditors() {
|
||||
let catcher = PasteCatcher(frame: .zero)
|
||||
let (editor, pasteboard) = makeEditor(behind: catcher)
|
||||
defer { pasteboard.releaseGlobally() }
|
||||
pasteboard.setString("plain words", forType: .string)
|
||||
pasteboard.setData(encodedImage(.png), forType: .png)
|
||||
|
||||
editor.paste(nil)
|
||||
#expect(catcher.pastes == 0)
|
||||
}
|
||||
|
||||
@Test("Preview mode takes no paste at all, so the window's branch owns it outright")
|
||||
func previewYieldsEverything() {
|
||||
let catcher = PasteCatcher(frame: .zero)
|
||||
let (editor, pasteboard) = makeEditor(behind: catcher)
|
||||
defer { pasteboard.releaseGlobally() }
|
||||
editor.isEditable = false
|
||||
pasteboard.setData(encodedImage(.png), forType: .png)
|
||||
|
||||
#expect(editor.validateUserInterfaceItem(pasteItem))
|
||||
editor.paste(nil)
|
||||
#expect(catcher.pastes == 1)
|
||||
}
|
||||
|
||||
@Test("Nothing behind to take it means a disabled row, not a swallowed gesture")
|
||||
func noTargetDisables() {
|
||||
let (editor, pasteboard) = makeEditor()
|
||||
defer { pasteboard.releaseGlobally() }
|
||||
pasteboard.setData(encodedImage(.png), forType: .png)
|
||||
|
||||
#expect(!editor.validateUserInterfaceItem(pasteItem))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user