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:
@@ -474,6 +474,23 @@ struct CardWindowHost: View {
|
||||
.onChange(of: placement.card.attachments, initial: true) { _, names in
|
||||
attachments.names = names
|
||||
}
|
||||
// **The hero is the snapshot's too**, and republished the same way, so the row that
|
||||
// offers "Remove Hero" is the row the card face is actually banding from (05 ▸
|
||||
// Attachments; 03-board-ui.md § Card face ▸ Hero image). A malformed key reads as no
|
||||
// hero, which is exactly what the face draws.
|
||||
.onChange(of: placement.card.hero.value, initial: true) { _, hero in
|
||||
attachments.hero = hero
|
||||
}
|
||||
// **⌘V in this window pastes a picture onto this card** (04-interactions.md ▸ Clipboard's
|
||||
// image-data branch). Here rather than inside `CardWindowView` because the availability
|
||||
// is the clipboard's observable reading and this is where a store, a card id and the
|
||||
// app-wide clipboard are all in scope at once — the same join `configureAttachments`
|
||||
// makes for the other two writes.
|
||||
.cardWindowImagePaste(
|
||||
store: store,
|
||||
cardID: placement.card.id,
|
||||
clipboard: appModel.clipboard
|
||||
)
|
||||
.onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in
|
||||
// Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session
|
||||
// folder rename moves the board, and rows resolving against where it used to be
|
||||
@@ -693,7 +710,7 @@ struct CardWindowHost: View {
|
||||
session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
|
||||
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
|
||||
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
|
||||
Self.configureAttachments(attachments, store: store, cardID: cardID)
|
||||
Self.configureAttachments(attachments, store: store, cardID: cardID, undo: session.undo)
|
||||
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
||||
}
|
||||
|
||||
@@ -811,13 +828,24 @@ struct CardWindowHost: View {
|
||||
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason:
|
||||
/// the target resolution is invisible in a running window until it is wrong, and this shape is
|
||||
/// what lets a test drive the real wiring rather than a re-typed copy of it.
|
||||
static func configureAttachments(_ attachments: CardAttachments, store: BoardStore, cardID: ItemID) {
|
||||
static func configureAttachments(
|
||||
_ attachments: CardAttachments,
|
||||
store: BoardStore,
|
||||
cardID: ItemID,
|
||||
undo: CardWindowUndo
|
||||
) {
|
||||
attachments.importFiles = { [weak store] urls in
|
||||
store?.importAttachments(urls, toCard: cardID)
|
||||
}
|
||||
attachments.removeFile = { [weak store] name in
|
||||
store?.removeAttachment(named: name, fromCard: cardID)
|
||||
}
|
||||
// **The window's own stack**, like every other gesture issued in this window
|
||||
// (13-native-undo.md ▸ Rules ▸ two levels): Set as Hero is a restyle, and a restyle made here
|
||||
// folds into this session's close step rather than landing on the board's.
|
||||
attachments.setHeroFile = { [weak store] name in
|
||||
store?.setHero(name, onCard: cardID, on: undo)
|
||||
}
|
||||
}
|
||||
|
||||
/// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw
|
||||
|
||||
@@ -201,6 +201,20 @@ public protocol ClipboardPasteboard: AnyObject {
|
||||
/// resulting `changeCount`.
|
||||
@discardableResult
|
||||
func write(manifest: Data, text: String) -> Int
|
||||
|
||||
/// **Every type identifier the pasteboard currently carries** — what the image-data branch
|
||||
/// classifies (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `PastedImage.flavor`).
|
||||
///
|
||||
/// A list rather than a set of yes/no questions, because the *rule* is a decision over a list and
|
||||
/// belongs in one place: adding a flavor to `PastedImage.verbatimTypes` must not also mean adding
|
||||
/// a method here. It subsumes `manifestData()`'s question too, and does not replace it — the
|
||||
/// manifest is read as bytes and decoded, which a type list cannot answer.
|
||||
func availableTypes() -> [String]
|
||||
|
||||
/// The bytes under one type, whatever it is — the general read behind `manifestData()`'s
|
||||
/// specific one, added for the image branch (which knows its type only at runtime, off the
|
||||
/// classification above).
|
||||
func data(forType type: String) -> Data?
|
||||
}
|
||||
|
||||
/// The real pasteboard.
|
||||
@@ -226,6 +240,19 @@ public final class SystemPasteboard: ClipboardPasteboard {
|
||||
pasteboard.data(forType: Self.type)
|
||||
}
|
||||
|
||||
/// `NSPasteboard.types` — the **first item's** types, which is what "the clipboard's payload"
|
||||
/// means for every producer this branch cares about: a screenshot, a browser's Copy Image, a
|
||||
/// Finder copy, and this app's own write are all single-item writes. A multi-item pasteboard's
|
||||
/// later items are deliberately not consulted; pasting the second image of a five-image copy is a
|
||||
/// gesture nobody has asked for and would need a target grammar of its own.
|
||||
public func availableTypes() -> [String] {
|
||||
(pasteboard.types ?? []).map(\.rawValue)
|
||||
}
|
||||
|
||||
public func data(forType type: String) -> Data? {
|
||||
pasteboard.data(forType: NSPasteboard.PasteboardType(type))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func write(manifest: Data, text: String) -> Int {
|
||||
pasteboard.clearContents()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
import os
|
||||
|
||||
// MARK: - PastedImage
|
||||
|
||||
/// **A picture on the pasteboard, as a file this app could write** (04-interactions.md ▸ Clipboard,
|
||||
/// the image-data branch ruled 2026-08-09) — which flavor to take, what the landed file is called,
|
||||
/// and whether the bytes travel verbatim or are re-encoded on the way.
|
||||
///
|
||||
/// ### Why this is a pure rule with no pasteboard in it
|
||||
///
|
||||
/// `NewCardTarget` and `PasteTarget`'s reason, one layer over: every clause below is a *decision*
|
||||
/// about a list of type identifiers, and a decision that can be a value function should be one — the
|
||||
/// menu item's `disabled`, the paste's own refusal, and the tests all read the same answer instead of
|
||||
/// three hand-kept-in-sync conditions. The bytes are fetched by whoever owns the pasteboard seam
|
||||
/// (`ClipboardStore`); nothing here touches `NSPasteboard`.
|
||||
///
|
||||
/// ### The precedence, which is the whole of the classification
|
||||
///
|
||||
/// 1. **The app's own clipboard type wins outright.** A Lanework copy on the pasteboard is a board
|
||||
/// payload and pastes as cards or lanes exactly as it always did — an image flavor riding beside
|
||||
/// it (there is none today, but a future manifest could carry a preview) must never divert ⌘V.
|
||||
/// 2. **File URLs are somebody else's branch.** A Finder copy puts `public.file-url` down, sometimes
|
||||
/// with an image flavor beside it, and "the pasteboard's payload is IMAGE DATA (no file URL)" is
|
||||
/// the ruling's own parenthesis. A file URL is a *reference* to something the user already has
|
||||
/// filed; taking a second copy of it into `attachments/` behind their back is a different gesture
|
||||
/// with a different answer, and this branch declines rather than guessing at it.
|
||||
/// 3. **Raw image data is the fallback**, which is the screenshot (⌃⇧⌘4), the browser's Copy Image,
|
||||
/// and Preview's ⌘C.
|
||||
///
|
||||
/// ### The format rule
|
||||
///
|
||||
/// **A file-shaped flavor is kept byte for byte** — PNG, JPEG, GIF, HEIC, WebP. These are the
|
||||
/// encodings a file on disk is already written in, so re-encoding one would cost either fidelity (a
|
||||
/// JPEG round-tripped through PNG is bigger *and* still carries the original's artefacts) or the
|
||||
/// picture itself (a GIF's animation does not survive a single-frame decode). The extension is that
|
||||
/// type's own preferred one, so the landed file opens in Preview and QuickLooks in the sidebar with
|
||||
/// no ceremony.
|
||||
///
|
||||
/// **Everything else is re-encoded to PNG** — in practice `public.tiff`, AppKit's lossless
|
||||
/// interchange flavor, which is what a screenshot and a Preview copy put down beside their PNG and
|
||||
/// what a great many apps offer *instead* of one. TIFF is an interchange encoding rather than a file
|
||||
/// people want sitting in a card's folder: a 5K screenshot is tens of megabytes as TIFF and about a
|
||||
/// tenth of that as PNG, both lossless. `.bmp` rides the same branch for the same reason.
|
||||
///
|
||||
/// The preference order is PNG first and TIFF last, so the overwhelmingly common paste — a screenshot
|
||||
/// offering `public.png` and `public.tiff` together — lands as the PNG it already is, with no decode
|
||||
/// and no re-encode at all.
|
||||
public enum PastedImage {
|
||||
|
||||
// MARK: The name
|
||||
|
||||
/// **The name a pasted image lands under** — Finder's own shape for a file that arrives with no
|
||||
/// name of its own ("Pasted Image.png", then "Pasted Image 2.png", …).
|
||||
///
|
||||
/// Only the stem is here: the extension is the flavor's (`Flavor.fileExtension`), and the
|
||||
/// collision ladder is `BoardWriter.freshName`'s, reached by handing the import path a temporary
|
||||
/// file with this name on it. Nothing in this app climbs a second ladder — "Finder-style rename
|
||||
/// on collision is one rule wherever the app has to find a free name".
|
||||
public static let baseName = "Pasted Image"
|
||||
|
||||
/// **The board backdrop's own stem** — the same rule one level up (03-board-ui.md § Styling ▸
|
||||
/// Capabilities), so a pasted background is as recognizable in a board folder as a pasted
|
||||
/// attachment is in a card's.
|
||||
///
|
||||
/// Its own constant rather than `baseName` reused: the two files land in different folders for
|
||||
/// different reasons, and a board folder holding something called "Pasted Image.png" would say
|
||||
/// nothing about what it is for. `FacetsGenerator.fileName` is the sibling this is modeled on.
|
||||
public static let backgroundBaseName = "Pasted Background"
|
||||
|
||||
// MARK: The flavor
|
||||
|
||||
/// One readable image payload on the pasteboard: where to read it from, what to write, and what
|
||||
/// to call it.
|
||||
public struct Flavor: Equatable, Sendable {
|
||||
|
||||
/// The pasteboard type identifier the bytes come from.
|
||||
public let type: String
|
||||
|
||||
/// The landed file's extension — the flavor's own for a verbatim write, `png` for a
|
||||
/// converted one.
|
||||
public let fileExtension: String
|
||||
|
||||
/// Whether the bytes are re-encoded on the way (see the type comment's format rule).
|
||||
public let convertsToPNG: Bool
|
||||
|
||||
/// The file name a paste of this flavor mints, before the Finder ladder ever sees it.
|
||||
public var fileName: String { "\(PastedImage.baseName).\(fileExtension)" }
|
||||
|
||||
/// The board-backdrop file name for the same flavor.
|
||||
public var backgroundFileName: String { "\(PastedImage.backgroundBaseName).\(fileExtension)" }
|
||||
}
|
||||
|
||||
// MARK: Classification
|
||||
|
||||
/// The flavors kept verbatim, **in this app's preference order** — not the pasteboard's, which
|
||||
/// is the *owner's* ranking of what it thinks a taker wants and has no idea a file is about to
|
||||
/// be written.
|
||||
///
|
||||
/// PNG leads because it is lossless, universally readable, and the flavor a screenshot already
|
||||
/// carries. JPEG follows so a photograph copied out of a browser lands as the JPEG it is rather
|
||||
/// than as a PNG several times its size. GIF, HEIC and WebP are here so that a source offering
|
||||
/// only one of them is still a paste rather than a refusal.
|
||||
public static let verbatimTypes: [UTType] = [.png, .jpeg, .gif, .heic, .webP]
|
||||
|
||||
/// The flavors re-encoded to PNG — the interchange bitmaps (see the type comment).
|
||||
public static let convertedTypes: [UTType] = [.tiff, .bmp]
|
||||
|
||||
/// What this pasteboard offers the image branch, or `nil` when the branch does not apply.
|
||||
///
|
||||
/// - Parameter hasBoardItems: whether the app's own clipboard type is present and readable —
|
||||
/// clause 1 of the precedence. Passed in rather than read here because deciding *that* is
|
||||
/// `ClipboardManifest`'s job and this type has no pasteboard.
|
||||
/// - Parameter types: every type identifier the pasteboard currently carries.
|
||||
public static func flavor(hasBoardItems: Bool, types: [String]) -> Flavor? {
|
||||
guard !hasBoardItems, !carriesFileURL(types) else { return nil }
|
||||
|
||||
let offered = Set(types)
|
||||
for type in verbatimTypes where offered.contains(type.identifier) {
|
||||
// A registered type with no preferred extension is not something a file can be named
|
||||
// after; skipping it lets the ladder fall through to the converted branch rather than
|
||||
// minting "Pasted Image." with nothing after the dot.
|
||||
guard let ext = type.preferredFilenameExtension else { continue }
|
||||
return Flavor(type: type.identifier, fileExtension: ext, convertsToPNG: false)
|
||||
}
|
||||
for type in convertedTypes where offered.contains(type.identifier) {
|
||||
return Flavor(type: type.identifier, fileExtension: "png", convertsToPNG: true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Whether the pasteboard is carrying a file reference — clause 2 of the precedence.
|
||||
///
|
||||
/// Conformance rather than equality with `public.file-url`, for `FinderDrop.isDirectory
|
||||
/// (typeIdentifiers:)`'s reason: a source is free to declare a subtype of it, and the rule is
|
||||
/// about what the value *is*. A type the system does not know is not a file URL, which is the
|
||||
/// same optimistic reading a drag's unknown types get.
|
||||
public static func carriesFileURL(_ types: [String]) -> Bool {
|
||||
types.contains { UTType($0)?.conforms(to: .fileURL) ?? false }
|
||||
}
|
||||
|
||||
// MARK: The bytes
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "paste-image")
|
||||
|
||||
/// The bytes this flavor writes, given what the pasteboard handed over — `raw` itself for a
|
||||
/// verbatim flavor, a PNG re-encoding for a converted one, and `nil` when the payload turns out
|
||||
/// not to be a decodable image at all.
|
||||
///
|
||||
/// A `nil` here is the honest refusal: the pasteboard *declared* a type it cannot back up, and
|
||||
/// writing an unreadable file into somebody's card folder is worse than doing nothing. The
|
||||
/// caller treats it exactly as it treats an empty pasteboard.
|
||||
public static func encode(_ raw: Data, as flavor: Flavor) -> Data? {
|
||||
guard flavor.convertsToPNG else { return raw.isEmpty ? nil : raw }
|
||||
return pngData(from: raw)
|
||||
}
|
||||
|
||||
/// A bitmap payload re-encoded as PNG, through ImageIO.
|
||||
///
|
||||
/// **ImageIO rather than `NSBitmapImageRep`**, which is the same call `BoardBackdrop.decode`
|
||||
/// makes and for the same reasons: it is the framework that actually owns the codecs, it is
|
||||
/// `Sendable`-clean and main-actor-free, and it needs no AppKit image cache in the middle. The
|
||||
/// full image is decoded rather than a thumbnail — this is a *conversion*, and downsampling a
|
||||
/// picture the user pasted would silently cost them resolution they never agreed to lose.
|
||||
///
|
||||
/// Alpha survives, because a PNG destination writing a CGImage with an alpha channel keeps it —
|
||||
/// which matters for exactly the payload this branch sees most, a screenshot of a rounded window.
|
||||
public static func pngData(from data: Data) -> Data? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let image = CGImageSourceCreateImageAtIndex(
|
||||
source, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary
|
||||
)
|
||||
else {
|
||||
logger.debug("pasteboard bitmap could not be decoded")
|
||||
return nil
|
||||
}
|
||||
let output = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(
|
||||
output, UTType.png.identifier as CFString, 1, nil
|
||||
) else { return nil }
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
logger.debug("pasteboard bitmap could not be re-encoded as PNG")
|
||||
return nil
|
||||
}
|
||||
return output as Data
|
||||
}
|
||||
|
||||
// MARK: Which attachments can be a hero
|
||||
|
||||
/// Whether an attachment named `name` is one **"Set as Hero" may point at** — an image, by its
|
||||
/// name's own extension (05-card-window.md ▸ Attachments; 03-board-ui.md § Card face ▸ Hero
|
||||
/// image).
|
||||
///
|
||||
/// **By extension rather than by opening the file**, deliberately. The context menu is built
|
||||
/// while the pointer is going down on a row, and the sidebar may be showing a hundred of them;
|
||||
/// a per-row `CGImageSourceCreateWithURL` to decide whether a menu row is offered is exactly the
|
||||
/// kind of disk touch the card window keeps out of a body evaluation. The cost of being wrong is
|
||||
/// nothing either way: a name whose extension lies renders as no band at all (`CardHeroImage`'s
|
||||
/// structural degrade), and an image the system does not recognize by extension is simply not
|
||||
/// offered the row — the `hero` key is still hand-writable, which is what it was born as.
|
||||
public static func isImageName(_ name: String) -> Bool {
|
||||
let ext = (name as NSString).pathExtension
|
||||
guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return false }
|
||||
return type.conforms(to: .image)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user