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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user