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
187 lines
9.7 KiB
Swift
187 lines
9.7 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - The Edit menu's clipboard row
|
|
|
|
/// Edit ▸ Cut / Copy / Paste (⌘X / ⌘C / ⌘V) on the board — 11-command-nexus.md's Edit row, whose
|
|
/// scope is "Board window: cards and lanes … in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard
|
|
/// restore path … paste never targets the trash; text editors: standard text clipboard".
|
|
///
|
|
/// ### Why this is a responder answer and not three menu items
|
|
///
|
|
/// **Select All's precedent, exactly** (`BoardView`): the standard Edit menu already carries these
|
|
/// three, and AppKit dispatches `cut:`/`copy:`/`paste:` down the responder chain, so the board
|
|
/// answers them as a responder. Adding items of our own would put a second "Copy"-titled row in the
|
|
/// menus, which titles-are-API forbids outright (04-interactions.md ▸ Configurable bindings) — a
|
|
/// custom binding is stored against a title, and two rows sharing one would be ambiguous.
|
|
///
|
|
/// ### Availability is the handler's presence
|
|
///
|
|
/// `onCommand(_:perform:)` takes an **optional** action, and a `nil` action means the view does not
|
|
/// respond to that selector at all — which is precisely what AppKit's automatic menu validation
|
|
/// reads. So attaching the handler conditionally *is* the validation: there is one condition per
|
|
/// command, it decides both whether the item is enabled and whether the gesture does anything, and
|
|
/// the two can never disagree because they are the same expression.
|
|
///
|
|
/// The conditions themselves live on `ClipboardStore` (`canCopy`/`canCut`/`canPaste`), beside the
|
|
/// gestures they gate, for the reason every rule in this codebase that can be a named predicate is
|
|
/// one: an item that is going to no-op should not look available.
|
|
///
|
|
/// ### The focused-editor rule, twice over
|
|
///
|
|
/// A focused text field consumes these selectors natively, so ⌘X/⌘C/⌘V inside an inline title editor
|
|
/// stay text operations without anything here doing the arithmetic. The predicates still refuse while
|
|
/// an editor is open (04 ▸ Grammar: "board-scoped menu commands … disable via menu validation"),
|
|
/// which is belt over braces — but a board command that stayed armed under an editor is exactly the
|
|
/// fall-through 04's fixed grammar is careful to rule out.
|
|
extension View {
|
|
|
|
/// Attaches the board's clipboard responders, each only while its command applies.
|
|
func boardClipboardCommands(store: BoardStore, clipboard: ClipboardStore) -> some View {
|
|
self
|
|
.onCommand(#selector(NSText.cut(_:)), perform: clipboard.canCut(from: store) ? {
|
|
clipboard.cut(from: store)
|
|
} : nil)
|
|
.onCommand(#selector(NSText.copy(_:)), perform: clipboard.canCopy(from: store) ? {
|
|
clipboard.copy(from: store)
|
|
} : nil)
|
|
.onCommand(#selector(NSText.paste(_:)), perform: Self.pasteAction(store: store, clipboard: clipboard))
|
|
}
|
|
|
|
/// **⌘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 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 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` — 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) }
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - The card window's ⌘V
|
|
|
|
extension View {
|
|
|
|
/// **⌘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 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
|
|
/// comment edit stays a text paste and this responder never sees the selector — which is the
|
|
/// board's own "focused-editor rule, twice over" arriving in the window where it matters most.
|
|
/// 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 cardWindowPaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
|
|
onCommand(
|
|
#selector(NSText.paste(_:)),
|
|
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
|
|
|
|
/// **Edit ▸ Paste as Board Background** — the pasteboard's picture into the board folder, with
|
|
/// `background.image` pointed at it (03-board-ui.md § Styling ▸ Capabilities; ruled 2026-08-09).
|
|
///
|
|
/// ### Why this is a row of its own rather than another ⌘V branch
|
|
///
|
|
/// ⌘V has a target: the anchor card, or the card window's card. A board's backdrop is not on that
|
|
/// path at all — it is one value per board, reachable with nothing selected — so folding it into the
|
|
/// paste selector would mean either a modifier nobody could discover or ⌘V meaning two different
|
|
/// things depending on the selection. A named row says what it does, and the Nexus's "no default
|
|
/// chord" posture covers the rest: it remaps like any other item.
|
|
///
|
|
/// **Board window only**, which needs no clause: the row reads `\.boardStore`, and a card window or
|
|
/// the welcome window in front means there is no focused board store and the item is disabled.
|
|
///
|
|
/// The title is API (04-interactions.md ▸ Configurable bindings) and is unique across the menu bar.
|
|
struct PasteBoardBackgroundCommand: View {
|
|
|
|
let clipboard: ClipboardStore
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Paste as Board Background") {
|
|
guard let store else { return }
|
|
clipboard.pasteBoardBackground(into: store)
|
|
}
|
|
.disabled(!isEnabled)
|
|
}
|
|
|
|
private var isEnabled: Bool {
|
|
guard let store else { return false }
|
|
return clipboard.canPasteBoardBackground(into: store)
|
|
}
|
|
}
|
|
|
|
// MARK: - The deferred cut's treatment
|
|
|
|
extension View {
|
|
|
|
/// **Cut items dim in place until paste moves them** (04-interactions.md ▸ Clipboard).
|
|
///
|
|
/// The same reduced opacity a trash card wears while it is being dragged out, and for the same reason:
|
|
/// the item is still there, still selectable, still the user's — it is simply spoken for. A cut
|
|
/// item is deliberately *not* lifted out of the layout the way a dragged one is; a Finder-style
|
|
/// deferred cut promises the board looks unchanged until the paste lands.
|
|
///
|
|
/// Membership is read straight off `TransientBoardState.pendingCut`, which is where the rules
|
|
/// already live: a reload ejects a member that crossed into the trash or vanished (so a deleted cut card undims by
|
|
/// itself), and `ClipboardStore` clears the set outright when the cut is consumed or voided.
|
|
func cutTreatment(of id: ItemID, in store: BoardStore) -> some View {
|
|
opacity(store.transient.pendingCut.ids.contains(id) ? ClipboardTreatment.dimmedOpacity : 1)
|
|
}
|
|
}
|
|
|
|
/// The one number the cut's treatment is (03-board-ui.md § Motion keeps every duration and curve in
|
|
/// `Motion`; this is neither, but it is the same "no literal at a call site" rule applied to the one
|
|
/// value three views share).
|
|
enum ClipboardTreatment {
|
|
static let dimmedOpacity: Double = 0.45
|
|
}
|