A picture off the pasteboard becomes a card's file, its hero, or the board's backdrop

⌘V grows an image-data branch, below the app's own clipboard format and refused
outright while a file URL is on the pasteboard: a screenshot or a browser's Copy
Image lands as "Pasted Image.png" in the anchor card's attachments/, through the
very import path Finder file drops and ⇧⌘A take — one bracket, one Finder-style
collision ladder, one set of banners, and the same silence a drop's arrival has.
A card window's ⌘V pastes onto its own card; a focused text field still wins the
selector natively. A file-shaped flavor travels byte for byte, PNG preferred when
several are offered; TIFF and BMP are re-encoded to PNG, being interchange
encodings rather than files anyone wants in a folder.

The hero key gets the setter it was born owing: "Set as Hero" on any image row of
the attachment list, "Remove Hero" on the row that holds it, with menu-bar twins
so the context entry is nobody's only home. It writes as a restyle — one key, one
bracket, one invertible step on the window's own stack — and replaces rather than
refusing, because a card has one hero and the row that has it says Remove instead.

Edit ▸ Paste as Board Background is the same payload's other destination, taking
the existing background.image convention at its word: the picture into the board
folder as "Pasted Background.png", the colour subkey untouched, the generator's
overwrite-our-own-name rule inherited and its echo memo taught to tell the two
producers apart.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 02:43:53 -04:00
parent b18f7ca609
commit ca5d45156b
21 changed files with 1792 additions and 42 deletions
+168
View File
@@ -66,6 +66,21 @@ public final class ClipboardStore {
/// happened to invalidate the menu.
public private(set) var payload: ClipboardManifest?
/// **The image-data branch's reading of the same pasteboard**, as of the same `refresh()`
/// `nil` when there is no picture to paste, when a board payload outranks one, or when the
/// pasteboard carries file URLs (04-interactions.md Clipboard, ruled 2026-08-09;
/// `PastedImage.flavor(hasBoardItems:types:)` holds the precedence and this holds its answer).
///
/// Observed beside `payload` and refreshed in the same breath, for `payload`'s exact reason: the
/// three surfaces that turn on it the board's V fallback, the card window's V, and Edit
/// Paste as Board Background are menu-validated, and a computed pasteboard read would leave
/// every one of them stale until something else happened to rebuild the menu.
///
/// **Two readings, never two reads**: one `refresh()` reads the pasteboard once and fills both,
/// which is what makes "a board payload wins" a property of the code rather than an ordering two
/// call sites have to remember.
public private(set) var imagePayload: PastedImage.Flavor?
/// The staging directory public because the tests assert on what it holds after a copy, a
/// paste and a sweep, exactly as `BoardRegistry.storageURL` is public for its tests.
@ObservationIgnored public let stagingRoot: URL
@@ -271,6 +286,153 @@ public final class ClipboardStore {
}
}
// MARK: - Paste the image-data branch
// **V's fallback, not a second command** (04-interactions.md Clipboard, ruled 2026-08-09).
// A pasteboard carrying raw image data and no file URL pastes the picture into a card's
// `attachments/` the screenshot, the browser's Copy Image, Preview's C. Everything about it
// is deliberately the *existing* machinery seen from one branch over:
//
// - **The precedence is `refresh()`'s**, which fills `payload` and `imagePayload` from one read
// and can therefore never let a picture divert a board paste.
// - **The write is `BoardStore.importAttachments(_:toCard:)`** the same call a Finder file drop
// and the card window's A make. That is what buys the Finder-style collision rename, the
// `performWrite` bracket (one app-mediated reload, the read-only lock, the banner on failure),
// the echo ledger's receipt, and the staging rules, without a second import path in the app to
// keep in step with the first.
// - **The name is minted by writing a temp file** rather than by asking the Writer for a free
// name and then writing under it: `importFiles` takes source URLs and climbs its own ladder, so
// handing it a file already called "Pasted Image.png" is how a paste gets Finder's answer
// rather than a second implementation of it.
// - **It registers no undo step**, exactly like every other attachment arrival (13-native-undo.md
// Out of scope: "attachment add/remove registers no undo step in v1"). A paste that landed a
// file is an import, and imports are not on the stack half a pair would be worse than none.
// - **It announces exactly as a file drop does**, which is to say the arrival is silent: the
// write is app-mediated, so the reload it produces carries receipts and the announcer's ladder
// is quiet by construction (10-accessibility.md "app-mediated echoes never do"). What the
// user gets is what a drop gives them: the row appearing in the attachments section and the
// card face's chip counting one higher.
/// Whether V would paste a picture into `store`'s **anchor card** the board window's branch.
///
/// Three clauses. The board accepts board mutations (the lock and the focused-editor rule, exactly
/// as `canPaste(into:)` reads them); there is a picture on the pasteboard; and the selection
/// anchors a *card*, because an attachment belongs to one. A lane selection, an empty selection
/// and a trash selection all anchor no card and therefore offer nothing here which is
/// `PasteTarget.card`'s answer, so the item's availability and the paste's own refusal are the
/// same expression.
public func canPasteImage(into store: BoardStore) -> Bool {
guard store.acceptsBoardMutations, imagePayload != nil else { return false }
return PasteTarget.card(selection: store.selection, snapshot: store.snapshot) != nil
}
/// V's image branch on the board resolves the anchor card and pastes into it.
@discardableResult
public func pasteImage(into store: BoardStore) -> Bool {
refresh()
guard canPasteImage(into: store),
let cardID = PasteTarget.card(selection: store.selection, snapshot: store.snapshot)
else { return false }
return pasteImage(intoCard: cardID, in: store)
}
/// Whether V would paste a picture into this **named** card the card window's branch, where
/// the target is the window's own card rather than a selection's anchor.
///
/// The lock clause is `!store.isReadOnly` rather than `acceptsBoardMutations`, which is
/// `CardAttachments.isEditable`'s reading and the right one here: the focused-editor half of
/// `acceptsBoardMutations` is about *this board window's* inline title editor, and a card window
/// has no business going dead because a board window behind it is mid-rename. A focused text
/// field in the card window still wins V natively, which is the rule that actually matters here
/// and needs no arithmetic (`ClipboardCommands`' focused-editor note).
///
/// The card must be on the **board side**: `BoardStore.importAttachments` refuses a trashed card
/// outright, and offering a row that would no-op is exactly what the codebase's named predicates
/// exist to prevent.
public func canPasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
guard !store.isReadOnly, imagePayload != nil else { return false }
return BoardStore.boardItem(cardID, in: store.snapshot)?.cardID != nil
}
/// Pastes the pasteboard's picture into `cardID`'s `attachments/`.
///
/// - Returns: whether a file was handed to the import path. `false` is every way this can decline
/// no picture, a card that is not there, a pasteboard that declared a type it could not back
/// up, or a temp file that would not write and every one of them writes nothing at all.
@discardableResult
public func pasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
refresh()
guard canPasteImage(intoCard: cardID, in: store), let flavor = imagePayload else { return false }
guard let raw = pasteboard.data(forType: flavor.type),
let bytes = PastedImage.encode(raw, as: flavor)
else {
// The pasteboard named a flavor it cannot produce, or produced bytes that are not an
// image. Nothing is written and nothing is said: the honest outcome of a pasteboard that
// lied is the one where the card is untouched.
Self.logger.debug("image paste declined — the declared flavor produced no usable bytes")
return false
}
guard let staged = Self.stageForImport(bytes, named: flavor.fileName) else { return false }
defer { try? FileManager.default.removeItem(at: staged.deletingLastPathComponent()) }
store.importAttachments([staged], toCard: cardID)
return true
}
/// Writes `bytes` to a private temp folder under `name`, and answers the file's URL.
///
/// **A folder per paste, not a shared scratch directory**: the file has to carry the exact name
/// the import ladder will start from ("Pasted Image.png"), so two pastes in flight would collide
/// on it and the folder is what the caller removes afterwards, which is one `removeItem`
/// instead of a file plus whatever else ended up beside it.
///
/// The app's own container temp directory, so this needs no sandbox grant and no bookmark: the
/// bytes came off the pasteboard, they are going into the board the app already holds, and this
/// is the few milliseconds in between.
private static func stageForImport(_ bytes: Data, named name: String) -> URL? {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("PastedImage-\(UUID().uuidString)", isDirectory: true)
guard (try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)) != nil
else { return nil }
let url = folder.appendingPathComponent(name)
guard (try? bytes.write(to: url)) != nil else {
try? FileManager.default.removeItem(at: folder)
return nil
}
return url
}
// MARK: - Paste the board backdrop
/// Whether Edit Paste as Board Background applies to `store` (03-board-ui.md § Styling
/// Capabilities; the `background` mapping's `image` subkey).
///
/// Two clauses and no third: a board that accepts mutations, and a picture on the pasteboard.
/// There is no target to resolve a board has exactly one backdrop which is what makes this
/// the one image-paste surface that stays live on a zero-lane board.
public func canPasteBoardBackground(into store: BoardStore) -> Bool {
store.acceptsBoardMutations && imagePayload != nil
}
/// Edit Paste as Board Background the picture into the board folder, `background.image`
/// pointed at it.
///
/// The bytes are prepared exactly as the attachment branch's are (same classification, same
/// format rule) and then handed to `BoardStore.applyPastedBackground(data:fileExtension:)`, which
/// owns the naming, the one bracket and the undo step. Nothing about the *file* is decided here.
@discardableResult
public func pasteBoardBackground(into store: BoardStore) -> Bool {
refresh()
guard canPasteBoardBackground(into: store), let flavor = imagePayload else { return false }
guard let raw = pasteboard.data(forType: flavor.type),
let bytes = PastedImage.encode(raw, as: flavor)
else {
Self.logger.debug("background paste declined — the declared flavor produced no usable bytes")
return false
}
return store.applyPastedBackground(data: bytes, fileExtension: flavor.fileExtension)
}
// MARK: - Paste
/// Where this paste is going, resolved **now** from the selection as it stands when V is
@@ -467,6 +629,12 @@ public final class ClipboardStore {
guard count != lastChangeCount else { return }
lastChangeCount = count
payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:))
// The image branch's whole precedence, applied here so it is applied once: a board payload
// outranks a picture, and a file URL means this is not the image branch's pasteboard at all.
imagePayload = PastedImage.flavor(
hasBoardItems: payload != nil,
types: pasteboard.availableTypes()
)
if let cut = armedCut, payload?.copyID != cut.copyID {
voidCut()
}