A file copied in Finder becomes a card's attachment — ⌘V's reserved clause finally answers
The image branch's precedence ladder always had a second clause: a file URL on the pasteboard suppresses it, "a different gesture with a different answer" that the code deliberately declined rather than guessed at. This fills it in: one or more file URLs paste through the same `importAttachments` a Finder drop takes — one collision ladder, one folder refusal (`FinderDrop.partition`), one set of banners — outranking raw image data riding beside it (a Finder-copied image file carries both; the actual file lands, not a re-encoded copy of its bytes) while still deferring to the app's own clipboard type. Both ⌘V surfaces read the same `ClipboardStore.fileURLPayload`, so the board's fallback and the card window's own branch stay in step by construction rather than by two hand-kept-in-sync checks. Fixed a real leak in the body editor's paste yield along the way: `public.file-url` conforms to `public.url`, which `NSTextView` legitimately reads for a pasted hyperlink, and `NSPasteboard.availableType(from:)` matches by conformance rather than exact type — so a Finder copy carrying a generic URL representation beside its file URL would have been silently swallowed as text and never reached the window's attachment branch at all. The yield now declines outright on any file-URL pasteboard before the generic capability check runs. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -48,22 +48,26 @@ extension View {
|
||||
.onCommand(#selector(NSText.paste(_:)), perform: Self.pasteAction(store: store, clipboard: clipboard))
|
||||
}
|
||||
|
||||
/// **⌘V's two branches as one optional handler** (04-interactions.md ▸ Clipboard, the image-data
|
||||
/// branch ruled 2026-08-09).
|
||||
/// **⌘V's three branches as one optional handler** (04-interactions.md ▸ Clipboard, the
|
||||
/// image-data branch ruled 2026-08-09; the file-URL branch ruled the same day).
|
||||
///
|
||||
/// The precedence is expressed as the order of these two `if`s and nowhere else, which is the
|
||||
/// The precedence is expressed as the order of these three `if`s and nowhere else, which is the
|
||||
/// same discipline the rest of this file states: availability *is* the handler's presence, so a
|
||||
/// board payload winning over a picture is one expression rather than a condition on one item and
|
||||
/// a matching negation on another. `ClipboardStore.refresh` has already made the two readings
|
||||
/// mutually exclusive at the source (`imagePayload` is `nil` whenever a board payload is
|
||||
/// readable), so this ordering is belt over braces — but it is the ordering a reader will look
|
||||
/// board payload winning over a file, and a file winning over a picture, is three expressions in
|
||||
/// order rather than a condition on one item and matching negations on the other two.
|
||||
/// `ClipboardStore.refresh` has already made the three readings mutually exclusive at the source
|
||||
/// (`imagePayload` and `fileURLPayload` are never both live, and neither is while a board payload
|
||||
/// is readable), so this ordering is belt over braces — but it is the ordering a reader will look
|
||||
/// for, and stating it here costs one line.
|
||||
///
|
||||
/// `nil` — neither branch applies — greys the standard Paste row out exactly as before.
|
||||
/// `nil` — no branch applies — greys the standard Paste row out exactly as before.
|
||||
private static func pasteAction(store: BoardStore, clipboard: ClipboardStore) -> (() -> Void)? {
|
||||
if clipboard.canPaste(into: store) {
|
||||
return { clipboard.paste(into: store) }
|
||||
}
|
||||
if clipboard.canPasteFiles(into: store) {
|
||||
return { clipboard.pasteFiles(into: store) }
|
||||
}
|
||||
if clipboard.canPasteImage(into: store) {
|
||||
return { clipboard.pasteImage(into: store) }
|
||||
}
|
||||
@@ -75,12 +79,17 @@ extension View {
|
||||
|
||||
extension View {
|
||||
|
||||
/// **⌘V in a card window pastes a picture into that card** (04-interactions.md ▸ Clipboard, the
|
||||
/// image-data branch; 05-card-window.md ▸ Attachments).
|
||||
/// **⌘V in a card window pastes onto that card** (04-interactions.md ▸ Clipboard, the image-data
|
||||
/// and file-URL branches; 05-card-window.md ▸ Attachments) — the file branch first, the picture
|
||||
/// branch behind it, `pasteAction`'s own precedence one window over.
|
||||
///
|
||||
/// The board's own responder shape, one window over and with one branch instead of two: there is
|
||||
/// no board payload a card window could paste — cards and lanes land on a *board* — so the card
|
||||
/// window answers `paste:` only for the image branch, and only while there is a picture to take.
|
||||
/// The board's own responder shape, one window over and with two branches instead of three: there
|
||||
/// is no board payload a card window could paste — cards and lanes land on a *board* — so the card
|
||||
/// window answers `paste:` only for the two attachment branches, and only while one of them has
|
||||
/// something to take. **One combined handler, not two `.onCommand`s for the same selector**: the
|
||||
/// precedence has to be one expression for the same reason `pasteAction` is, and layering a second
|
||||
/// responder over the same selector would leave the ordering to however SwiftUI happened to chain
|
||||
/// them rather than to this file.
|
||||
///
|
||||
/// **A focused text field still wins, with nothing here doing the arithmetic.** `NSTextView`
|
||||
/// consumes `paste:` natively, so ⌘V in the body editor, the comment composer or an inline
|
||||
@@ -89,14 +98,28 @@ extension View {
|
||||
/// It is also why this hangs on the window's whole content rather than on the attachments
|
||||
/// section: 05 makes the *window* the drop surface for files, and the paste is that sentence's
|
||||
/// keyboard twin.
|
||||
func cardWindowImagePaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
|
||||
func cardWindowPaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
|
||||
onCommand(
|
||||
#selector(NSText.paste(_:)),
|
||||
perform: clipboard.canPasteImage(intoCard: cardID, in: store) ? {
|
||||
clipboard.pasteImage(intoCard: cardID, in: store)
|
||||
} : nil
|
||||
perform: Self.cardWindowPasteAction(store: store, cardID: cardID, clipboard: clipboard)
|
||||
)
|
||||
}
|
||||
|
||||
/// The card window's own `pasteAction` — the file branch outranking the picture branch, exactly
|
||||
/// as `refresh()` orders them.
|
||||
private static func cardWindowPasteAction(
|
||||
store: BoardStore,
|
||||
cardID: ItemID,
|
||||
clipboard: ClipboardStore
|
||||
) -> (() -> Void)? {
|
||||
if clipboard.canPasteFiles(intoCard: cardID, in: store) {
|
||||
return { clipboard.pasteFiles(intoCard: cardID, in: store) }
|
||||
}
|
||||
if clipboard.canPasteImage(intoCard: cardID, in: store) {
|
||||
return { clipboard.pasteImage(intoCard: cardID, in: store) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edit ▸ Paste as Board Background
|
||||
|
||||
@@ -409,8 +409,8 @@ final class CardBodyTextView: NSTextView {
|
||||
// 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).
|
||||
/// arrived at the keyboard (04-interactions.md ▸ Clipboard, the image-data and file-URL branches;
|
||||
/// 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
|
||||
@@ -421,10 +421,22 @@ final class CardBodyTextView: NSTextView {
|
||||
/// 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 yield is by *capability*, not by content kind, for the image branch — `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 file-URL branch needs one content check, and here is why.** `acceptableDragTypes` above
|
||||
/// excludes `.fileURL` outright, but `readablePasteboardTypes`' own answer already omits it too —
|
||||
/// `importsGraphics` is `false` on this view, so AppKit does not offer it. The leak is one level
|
||||
/// up: `public.file-url` *conforms to* `public.url`, which this view legitimately does read (so a
|
||||
/// dragged or pasted web link still lands as text), and `NSPasteboard.availableType(from:)`
|
||||
/// matches by conformance, not exact type — so a Finder copy that also declares a generic URL
|
||||
/// representation beside its file URL would still read as "text this editor takes" through sheer
|
||||
/// ancestry, and the paste would never reach the window at all. `PastedImage.carriesFileURL` is
|
||||
/// the one predicate this whole feature already classifies file-URL pasteboards with
|
||||
/// (`ClipboardStore.fileURLPayload`); reusing it here rather than a second UTI walk is what keeps
|
||||
/// this check in step with that one.
|
||||
|
||||
/// 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).
|
||||
@@ -432,11 +444,15 @@ final class CardBodyTextView: NSTextView {
|
||||
|
||||
/// Whether this editor itself would take the current pasteboard.
|
||||
private var takesPasteboardAsText: Bool {
|
||||
isEditable && yieldPasteboard.availableType(from: readablePasteboardTypes) != nil
|
||||
guard isEditable else { return false }
|
||||
// A file URL is never this editor's, whatever generic ancestor type rides beside it on the
|
||||
// pasteboard (the doc block above) — the file-URL branch owns this paste instead.
|
||||
guard !PastedImage.carriesFileURL((yieldPasteboard.types ?? []).map(\.rawValue)) else { return false }
|
||||
return 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
|
||||
/// The responder behind this view that answers `paste:` — the card window's file or image branch
|
||||
/// when either is armed (`cardWindowPaste`; 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
|
||||
|
||||
Reference in New Issue
Block a user