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)
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ public final class CardWindowUndo {
|
||||
}
|
||||
|
||||
private static let fieldOrder: [ExpectedField.Kind] = [
|
||||
.title, .order, .width, .background, .backgroundImage, .icon, .iconColor, .body,
|
||||
.title, .order, .width, .background, .backgroundImage, .hero, .icon, .iconColor, .body,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,15 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
/// afterwards. `nil` is the absent subkey, exactly as everywhere else here.
|
||||
case backgroundImage(String?)
|
||||
|
||||
/// `hero` — the card's banner picture, named by the attachment row's "Set as Hero" / "Remove
|
||||
/// Hero" (`BoardStore.setHero(_:onCard:on:)`, ruled 2026-08-09).
|
||||
///
|
||||
/// Its own case beside `.icon` rather than a reading of it, for `.backgroundImage`'s reason: they
|
||||
/// are independent values on one card, and a step that named the hero must not stale because
|
||||
/// somebody picked a symbol afterwards. `nil` is the removed key — Remove Hero — exactly as
|
||||
/// everywhere else here.
|
||||
case hero(String?)
|
||||
|
||||
/// `icon` — the styling gesture's symbol dimension.
|
||||
case icon(String?)
|
||||
|
||||
@@ -69,6 +78,7 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
case .collapsed: .collapsed
|
||||
case .background: .background
|
||||
case .backgroundImage: .backgroundImage
|
||||
case .hero: .hero
|
||||
case .icon: .icon
|
||||
case .iconColor: .iconColor
|
||||
case .body: .body
|
||||
@@ -84,6 +94,7 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
case collapsed
|
||||
case background
|
||||
case backgroundImage
|
||||
case hero
|
||||
case icon
|
||||
case iconColor
|
||||
case body
|
||||
@@ -348,6 +359,7 @@ public enum HistoryStaleness {
|
||||
case let .collapsed(expected): equal(document.collapsed, expected)
|
||||
case let .background(expected): equal(document.background, expected)
|
||||
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
|
||||
case let .hero(expected): equal(document.hero, expected)
|
||||
case let .icon(expected): equal(document.icon, expected)
|
||||
case let .iconColor(expected): equal(document.iconColor, expected)
|
||||
case let .body(expected): document.body == expected
|
||||
|
||||
+20
-5
@@ -219,6 +219,11 @@ struct KanbanApp: App {
|
||||
ShareBoardCommand(appModel: appModel)
|
||||
RevealInFinderCommand()
|
||||
AddAttachmentCommand()
|
||||
// The attachment row's hero pair, as the menu-bar twins 11-command-nexus.md's
|
||||
// context-menu contract requires ("no function's only home"). Two rows rather than one
|
||||
// that renames itself, for titles-are-API's reason (`SetAsHeroCommand`).
|
||||
SetAsHeroCommand()
|
||||
RemoveHeroCommand()
|
||||
AddCommentCommand()
|
||||
|
||||
Divider()
|
||||
@@ -233,12 +238,22 @@ struct KanbanApp: App {
|
||||
// (`UndoCommands.swift`).
|
||||
UndoRedoCommands()
|
||||
|
||||
// The Edit menu: Find (⌘F), then its card-window stepping twins, placed after the standard
|
||||
// Cut/Copy/Paste/Select All group, which is where macOS puts Find. The clipboard items above
|
||||
// them stay the system's, answered by the board as a responder (`ClipboardCommands.swift`) —
|
||||
// a second item sharing one of those titles is what titles-are-API forbids. Undo and Redo
|
||||
// were the system's too until the latch (the group just above).
|
||||
// The Edit menu: Paste as Board Background, then Find (⌘F) and its card-window stepping
|
||||
// twins, placed after the standard Cut/Copy/Paste/Select All group, which is where macOS puts
|
||||
// Find. The clipboard items above them stay the system's, answered by the board as a
|
||||
// responder (`ClipboardCommands.swift`) — a second item sharing one of those titles is what
|
||||
// titles-are-API forbids. Undo and Redo were the system's too until the latch (the group just
|
||||
// above).
|
||||
//
|
||||
// **Paste as Board Background joined 2026-08-09** with the image-data paste branch: it sits
|
||||
// directly under the system's Paste because it is the one paste this app has that ⌘V could
|
||||
// not carry — a backdrop has no selection to target, so it needs a name rather than a
|
||||
// modifier (`PasteBoardBackgroundCommand`). No default chord, like Welcome's row.
|
||||
CommandGroup(after: .pasteboard) {
|
||||
PasteBoardBackgroundCommand(clipboard: appModel.clipboard)
|
||||
|
||||
Divider()
|
||||
|
||||
FindCommand()
|
||||
FindSteppingCommands()
|
||||
}
|
||||
|
||||
@@ -296,15 +296,21 @@ public final class BoardStore: HealHost {
|
||||
/// refreshes nothing — the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
|
||||
public private(set) var landedReloads: Int = 0
|
||||
|
||||
/// **The generated background this store wrote, and the reload count it was written at** — the
|
||||
/// reroll's echo (`generatedBackgroundName(replacing:inRoot:)`, which is the only reader and
|
||||
/// carries the whole reasoning).
|
||||
/// **The board image this store wrote, and the reload count it was written at** — the reroll's
|
||||
/// echo (`boardImageName(base:replacing:inRoot:)`, which is the only reader and carries the whole
|
||||
/// reasoning).
|
||||
///
|
||||
/// **`base` is carried beside the name** because there are two producers now — the generator's
|
||||
/// `facets.png` and a pasted `Pasted Background.png` (ruled 2026-08-09) — and the echo's claim is
|
||||
/// "this store already owns *that family's* name since the last reload". Without it, a paste
|
||||
/// following a reroll inside one reload window would read `facets.png` as its own and overwrite a
|
||||
/// file it never wrote.
|
||||
///
|
||||
/// `@ObservationIgnored` because nothing renders it: it is bookkeeping about a file name, and a
|
||||
/// view that redrew when it changed would be redrawing for the write it is already going to be
|
||||
/// told about by the reload.
|
||||
@ObservationIgnored
|
||||
var generatedBackgroundEcho: (name: String, reloads: Int)?
|
||||
var generatedBackgroundEcho: (name: String, base: String, reloads: Int)?
|
||||
|
||||
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
|
||||
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
|
||||
@@ -2055,7 +2061,9 @@ public final class BoardStore: HealHost {
|
||||
let root = rootURL
|
||||
let priorImage = snapshot.backgroundImage
|
||||
let priorColor = snapshot.background
|
||||
let name = generatedBackgroundName(replacing: priorImage.value, inRoot: root)
|
||||
let name = boardImageName(
|
||||
base: FacetsGenerator.fileName, replacing: priorImage.value, inRoot: root
|
||||
)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.writeBoardImage(
|
||||
@@ -2070,7 +2078,7 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
generatedBackgroundEcho = (name: name, reloads: landedReloads)
|
||||
generatedBackgroundEcho = (name: name, base: FacetsGenerator.fileName, reloads: landedReloads)
|
||||
|
||||
// restyle → prior style (13-native-undo.md ▸ Rules). The board's own stack, never a window's:
|
||||
// there is no card here to have a session.
|
||||
@@ -2172,13 +2180,14 @@ public final class BoardStore: HealHost {
|
||||
document.setBackgroundImage(nil)
|
||||
}
|
||||
|
||||
/// The name a generated background is written under: **ours to overwrite**, or the next free one.
|
||||
/// The name a board image is written under: **ours to overwrite**, or the next free one.
|
||||
///
|
||||
/// `current` is what `background.image` says now. When that is already the generated name the
|
||||
/// file is this board's own output and is replaced in place — including when it has been deleted
|
||||
/// from the folder by hand, which is a board whose backdrop is broken and is exactly what
|
||||
/// regenerating fixes. Otherwise the Finder ladder decides, which yields the plain name when
|
||||
/// nothing holds it and `facets 2.png` when something does.
|
||||
/// `base` is the producer's own file name — `facets.png` for the generator, `Pasted
|
||||
/// Background.<ext>` for a paste — and `current` is what `background.image` says now. When that
|
||||
/// is already `base` the file is this board's own output and is replaced in place — including
|
||||
/// when it has been deleted from the folder by hand, which is a board whose backdrop is broken
|
||||
/// and is exactly what regenerating (or re-pasting) fixes. Otherwise the Finder ladder decides,
|
||||
/// which yields the plain name when nothing holds it and `facets 2.png` when something does.
|
||||
///
|
||||
/// ### The reroll's echo
|
||||
///
|
||||
@@ -2194,10 +2203,91 @@ public final class BoardStore: HealHost {
|
||||
/// is the better authority. Once a reload lands, the snapshot's `background.image` takes over and
|
||||
/// this memory stops being consulted — including when a hand edit pointed the board somewhere
|
||||
/// else in the meantime.
|
||||
private func generatedBackgroundName(replacing current: String?, inRoot root: URL) -> String {
|
||||
if current == FacetsGenerator.fileName { return FacetsGenerator.fileName }
|
||||
if let echo = generatedBackgroundEcho, echo.reloads == landedReloads { return echo.name }
|
||||
return BoardWriter.freshName(for: FacetsGenerator.fileName, in: root)
|
||||
///
|
||||
/// **The echo is only consulted for its own family** (`base`), which is what keeps two producers
|
||||
/// off each other: a paste landing inside the reroll's echo window must not read `facets.png` as
|
||||
/// a name it owns.
|
||||
private func boardImageName(base: String, replacing current: String?, inRoot root: URL) -> String {
|
||||
if current == base { return base }
|
||||
if let echo = generatedBackgroundEcho, echo.base == base, echo.reloads == landedReloads {
|
||||
return echo.name
|
||||
}
|
||||
return BoardWriter.freshName(for: base, in: root)
|
||||
}
|
||||
|
||||
// MARK: - Pasted background
|
||||
|
||||
/// **Applies a pasted picture as this board's backdrop** — Edit ▸ Paste as Board Background
|
||||
/// (03-board-ui.md § Styling ▸ Capabilities; 04-interactions.md ▸ Clipboard's image-data branch,
|
||||
/// ruled 2026-08-09).
|
||||
///
|
||||
/// `applyGeneratedBackground` line for line — the same two-writes-one-bracket ordering (picture
|
||||
/// first, so a failure never leaves the board naming a file that is not there), the same
|
||||
/// `.setBoardBackground` operation, the same swallowed failure, the same restyle step — with
|
||||
/// exactly two differences, both of them deliberate:
|
||||
///
|
||||
/// - **The colour is not touched.** The generator writes `background.color` because it *knows*
|
||||
/// its render's ground colour and wants the board to degrade to it; a picture off the
|
||||
/// pasteboard has no such figure, and inventing one (an average, a corner sample) would be this
|
||||
/// gesture quietly restyling a board the user only asked to give a backdrop. So the mapping's
|
||||
/// other subkey survives untouched, which is `setBackgroundImage`'s whole per-subkey contract.
|
||||
/// - **The name carries the payload's own extension** (`Pasted Background.png`, `.jpeg`, …),
|
||||
/// because the format rule keeps a file-shaped flavor verbatim (`PastedImage`). A board pasted
|
||||
/// twice in two formats therefore leaves the first file behind — the same quiet leftover
|
||||
/// choosing a solid colour over a generated background already leaves, and for the same reason:
|
||||
/// undo restores the *field*, and a field cannot point an undo back at bytes this gesture
|
||||
/// deleted.
|
||||
///
|
||||
/// **The undo restores the field, not the bytes** — `applyGeneratedBackground`'s own note,
|
||||
/// unchanged and for its reason: a re-paste over this board's own `Pasted Background.png`
|
||||
/// overwrites pixels nothing kept a copy of.
|
||||
///
|
||||
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
|
||||
/// coming".
|
||||
@discardableResult
|
||||
public func applyPastedBackground(data: Data, fileExtension: String) -> Bool {
|
||||
let root = rootURL
|
||||
let priorImage = snapshot.backgroundImage
|
||||
let base = "\(PastedImage.backgroundBaseName).\(fileExtension)"
|
||||
let name = boardImageName(base: base, replacing: priorImage.value, inRoot: root)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: data, named: name, inRoot: root, operation: .setBoardBackground
|
||||
)
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
document.setBackgroundImage(name)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
generatedBackgroundEcho = (name: name, base: base, reloads: landedReloads)
|
||||
|
||||
// restyle → prior image (13-native-undo.md ▸ Rules). The board's own stack, never a window's:
|
||||
// there is no card here to have a session. **Only the image subkey is declared**, which is
|
||||
// the field-level predicate at its narrowest: a colour chosen after this paste must not stale
|
||||
// the step, because this gesture never wrote a colour.
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restyle, kind: .board),
|
||||
undoExpects: [.present(root, .backgroundImage(name))],
|
||||
redoExpects: [.present(root, .backgroundImage(priorImage.value))]
|
||||
) { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
// A malformed prior reads as a removal, `restore(_:to:in:)`'s own rule and the one
|
||||
// the redo expectation above is written against.
|
||||
document.setBackgroundImage(priorImage.value)
|
||||
}
|
||||
} redo: { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
document.setBackgroundImage(name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Both subkeys, written into the mapping rather than over it (BackgroundField.swift) — spelled
|
||||
@@ -3667,6 +3757,112 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The hero image
|
||||
|
||||
/// **Points a card's `hero` key at one of its attachments, or removes it** — the card window
|
||||
/// attachment row's "Set as Hero" and "Remove Hero" (03-board-ui.md § Card face ▸ Hero image,
|
||||
/// whose "no in-app setter this version" line this ruling retires, 2026-08-09; 05-card-window.md
|
||||
/// ▸ Attachments).
|
||||
///
|
||||
/// ### It is a style write, and is shaped like one
|
||||
///
|
||||
/// One key on one card's `index.md`, through `updateIndex` inside one `performWrite` bracket,
|
||||
/// registering one **restyle** step — `applyStyle`'s shape with the batch collapsed to a single
|
||||
/// subject, because there is one: a hero belongs to a card, and the row that sets it names
|
||||
/// exactly one file. It takes `.style` as its operation for the same reason it takes the restyle
|
||||
/// phrase: nothing here writes a file, which is the whole of what made
|
||||
/// `WriteOperation.setBoardBackground` split off from `.style` one level up.
|
||||
///
|
||||
/// **It does register an undo step**, unlike its neighbours in this section. That is not an
|
||||
/// inconsistency with "attachment add/remove registers no undo step" (13-native-undo.md ▸ Out of
|
||||
/// scope): the reason that rule exists is that a removed *file* has nowhere to come back from, and
|
||||
/// this gesture moves no file at all — it edits a key whose before-value the step carries, which
|
||||
/// is the ordinary invertible frontmatter write every other style dimension already is.
|
||||
///
|
||||
/// ### Replacement, not refusal
|
||||
///
|
||||
/// "Set as Hero" on a card that already has one **replaces** it, and the row is simply absent on
|
||||
/// the row already holding the key (`CardAttachments.canSetHero(_:)`) — a card has one hero, the
|
||||
/// user picked a different picture, and making them Remove first would be ceremony. A call that
|
||||
/// would write what is already there is a no-op: `effective(_:against:)` is the same predicate
|
||||
/// `applyStyle` uses to decide a dimension changed nothing, so a redundant set costs no write, no
|
||||
/// reload and no undo step.
|
||||
///
|
||||
/// ### The guards are `importAttachments`'
|
||||
///
|
||||
/// **The board container and only it** — a trashed card's hero is not editable from a window that
|
||||
/// is dismissing itself — and a lane id is refused because a lane has no hero. `name` is not
|
||||
/// checked against the card's listing here: the surface only ever offers a row it is showing, and
|
||||
/// the schema's own reading refuses anything that is not a bare filename anyway
|
||||
/// (`FrontmatterDocument.hero`), so a name that has since gone lands a key that renders as no band
|
||||
/// — which is exactly what 03 says a hero naming a missing file does.
|
||||
///
|
||||
/// - Parameter name: the attachment's file name, or `nil` to remove the key.
|
||||
/// - Parameter window: the card window whose stack the step belongs on, when the gesture came
|
||||
/// from one (13-native-undo.md ▸ Rules ▸ two levels). A window-issued gesture also anchors by
|
||||
/// **card identity** rather than by path, `applyStyle`'s rule verbatim, so a lane move under an
|
||||
/// open window never stales it.
|
||||
/// - Returns: whether bytes reached disk.
|
||||
@discardableResult
|
||||
public func setHero(_ name: String?, onCard cardID: ItemID, on window: CardWindowUndo? = nil) -> Bool {
|
||||
guard let item = Self.boardItem(cardID, in: snapshot),
|
||||
let card = item.cardID,
|
||||
let subject = Self.card(cardID, in: snapshot)
|
||||
else { return false }
|
||||
|
||||
let prior = subject.hero
|
||||
let change: StyleChange = name.map { .set($0) } ?? .remove
|
||||
guard Self.effective(change, against: prior) != .keep else { return false }
|
||||
|
||||
let folder = rootURL
|
||||
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(card.rawValue, isDirectory: true)
|
||||
let anchor: HistoryAnchor = window != nil ? .card(cardID) : .path(folder)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a failure
|
||||
// names the card by the title it still has.
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.setStyleValue(name, for: FrontmatterKeys.hero)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restyle, kind: .card),
|
||||
subject: item.title,
|
||||
on: window,
|
||||
undoExpects: [.present(anchor, .hero(name))],
|
||||
redoExpects: [.present(anchor, .hero(prior.value))]
|
||||
) { store in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: try store.requiredFolder(for: anchor, .style(title: nil)),
|
||||
operation: .style(title: nil)
|
||||
) { document in
|
||||
// A malformed prior reads as a removal, `restore(_:to:in:)`'s own rule and the one
|
||||
// the redo expectation above is written against.
|
||||
Self.restore(prior, to: FrontmatterKeys.hero, in: &document)
|
||||
}
|
||||
} redo: { store in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: try store.requiredFolder(for: anchor, .style(title: nil)),
|
||||
operation: .style(title: nil)
|
||||
) { document in
|
||||
document.setStyleValue(name, for: FrontmatterKeys.hero)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// One live board-side card off a snapshot, by identity — `boardItem`'s sibling for a caller that
|
||||
/// needs the card's *fields* rather than its position.
|
||||
nonisolated static func card(_ id: ItemID, in snapshot: BoardModel) -> Card? {
|
||||
for lane in snapshot.lanes {
|
||||
if let card = lane.cards.first(where: { $0.id == id }) { return card }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - The loose-file carve-out
|
||||
|
||||
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
|
||||
|
||||
@@ -585,9 +585,12 @@ public enum FrontmatterKeys {
|
||||
/// carrying a separator is not a hero image spelled awkwardly, it is a value with no reading at
|
||||
/// all (`FrontmatterDocument.hero`).
|
||||
///
|
||||
/// **Opt-in, and hand-written**: there is no in-app setter this version (ruled 2026-08-09), so
|
||||
/// the key is written by an author or an agent and the app only ever reads it. It joins
|
||||
/// `schemaOwned` all the same — it is Lanework's to interpret, not an unknown key riding along.
|
||||
/// **Opt-in, and written from exactly one control**: the card window attachment row's "Set as
|
||||
/// Hero" / "Remove Hero" (re-ruled 2026-08-09, promoting the key's own "future work" note the day
|
||||
/// it landed; `BoardStore.setHero(_:onCard:on:)`). The row can only name a file the card already
|
||||
/// has, which is the key's grammar enforced by the surface rather than validated after the fact;
|
||||
/// hand-writing and agent-writing stay exactly as legal as they were. It joins `schemaOwned` for
|
||||
/// the reason it always did — it is Lanework's to interpret, not an unknown key riding along.
|
||||
public static let hero = "hero"
|
||||
|
||||
public static let created = "created"
|
||||
|
||||
@@ -128,6 +128,16 @@ enum AccessibilityPhrases {
|
||||
/// the dim is the sighted signal, this is the other one.
|
||||
static let cutPending = "cut, pending paste"
|
||||
|
||||
/// **The attachment row that is the card's hero** (05-card-window.md ▸ Attachments; ruled
|
||||
/// 2026-08-09) — carried as the row's *value*, beside its filename label.
|
||||
///
|
||||
/// The same reasoning `cutPending` states one field over: a sighted user sees the banner on the
|
||||
/// card face and knows which file made it, and without this the sidebar gives a VoiceOver user no
|
||||
/// way to tell one of five images from the other four. It is also what makes the row's own
|
||||
/// context menu legible — "Remove Hero" appearing on exactly one row is otherwise a menu that
|
||||
/// changes for no announced reason.
|
||||
static let heroAttachment = "hero image"
|
||||
|
||||
/// A card element's value — the attachment count and the comment count when the card has either
|
||||
/// (design ruling 2026-08-09, card e729e30a — the comments chip's face value gains "N comments"
|
||||
/// beside the existing attachment wording), the cut-pending phrase when it is staged for paste,
|
||||
|
||||
@@ -45,9 +45,94 @@ extension View {
|
||||
.onCommand(#selector(NSText.copy(_:)), perform: clipboard.canCopy(from: store) ? {
|
||||
clipboard.copy(from: store)
|
||||
} : nil)
|
||||
.onCommand(#selector(NSText.paste(_:)), perform: clipboard.canPaste(into: store) ? {
|
||||
clipboard.paste(into: store)
|
||||
} : nil)
|
||||
.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).
|
||||
///
|
||||
/// The precedence is expressed as the order of these two `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
|
||||
/// for, and stating it here costs one line.
|
||||
///
|
||||
/// `nil` — neither 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.canPasteImage(into: store) {
|
||||
return { clipboard.pasteImage(into: store) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card window's ⌘V
|
||||
|
||||
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).
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// **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 cardWindowImagePaste(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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,25 @@ enum PasteTarget {
|
||||
return Cards(laneID: lane.id, index: index)
|
||||
}
|
||||
|
||||
/// **Which card an image paste lands on** — the anchor card, and only a card (04-interactions.md
|
||||
/// ▸ Clipboard, the image-data branch ruled 2026-08-09: "paste targets a card … per the existing
|
||||
/// paste-target grammar").
|
||||
///
|
||||
/// `flattenAnchor` again rather than a rule of its own, because the ruling says "the existing
|
||||
/// paste-target grammar" and this is it: the last selected card in flatten order, which is
|
||||
/// already what a card payload anchors after and what ⌘N creates after.
|
||||
///
|
||||
/// **`nil` wherever the anchor is not a card**, which is the whole of the difference from
|
||||
/// `cards(selection:lastActiveLaneID:snapshot:)` — and the reason there is no last-active-lane
|
||||
/// fallback here. A board payload can *append to a lane*, so "nothing selected" still has an
|
||||
/// answer; a picture has to land in some card's `attachments/`, and there is no card the app
|
||||
/// could pick without inventing one. So an empty selection, a lane selection and a trash
|
||||
/// selection all answer `nil`, the menu greys out, and the user selects a card — rather than a
|
||||
/// screenshot silently arriving on whichever card the app guessed at.
|
||||
static func card(selection: ItemReferenceSet, snapshot: BoardModel) -> ItemID? {
|
||||
NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot)?.anchorCardID
|
||||
}
|
||||
|
||||
/// The lane payload's slot among the board's live lanes — **always an answer**, zero-lane board
|
||||
/// included, because lane paste "stays enabled and lands at the board's right end" whatever the
|
||||
/// board holds. That is what makes it the other way out of a board with no lanes.
|
||||
|
||||
@@ -48,6 +48,15 @@ public final class CardAttachments {
|
||||
/// stabler identity than an index across a reload that inserted a file above it.
|
||||
public var selected: String?
|
||||
|
||||
/// **The file this card's `hero` key names**, republished from the snapshot exactly as `names`
|
||||
/// is (`Card.hero`) — `nil` for a card with no hero, and for one whose key is malformed, which is
|
||||
/// the same "no band" the face renders (03-board-ui.md § Card face ▸ Hero image).
|
||||
///
|
||||
/// Here rather than derived in the section for `names`' reason: the sidebar must not be a second
|
||||
/// reading of the card able to disagree with the board face's. It drives one thing only — which
|
||||
/// of the two hero rows a row's context menu offers.
|
||||
public var hero: String?
|
||||
|
||||
/// Whether the section currently holds keyboard focus. Read by File ▸ Reveal in Finder, whose
|
||||
/// card-window scope is "the card's folder — the selected attachment's file instead when the
|
||||
/// attachments section is focused" (11-command-nexus.md).
|
||||
@@ -62,6 +71,11 @@ public final class CardAttachments {
|
||||
/// .removeAttachment(named:fromCard:)`.
|
||||
public var removeFile: ((String) -> Void)?
|
||||
|
||||
/// Points the card's `hero` key at a file, or removes it — filled by the host with `BoardStore
|
||||
/// .setHero(_:onCard:on:)`, this window's undo stack attached, so the step lands on the stack the
|
||||
/// gesture was issued on (13-native-undo.md ▸ Rules ▸ two levels).
|
||||
public var setHeroFile: ((String?) -> Void)?
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Derived
|
||||
@@ -105,6 +119,58 @@ public final class CardAttachments {
|
||||
removeFile?(name)
|
||||
}
|
||||
|
||||
/// **Set as Hero** — the row's file becomes the card's banner picture (05 ▸ Attachments, ruled
|
||||
/// 2026-08-09; 03-board-ui.md § Card face ▸ Hero image).
|
||||
public func setAsHero(_ name: String) {
|
||||
guard Self.canSetHero(name, hero: hero, names: names, isEditable: isEditable) else { return }
|
||||
setHeroFile?(name)
|
||||
}
|
||||
|
||||
/// **Remove Hero** — the key goes, the file stays. Removing the *hero* is not removing the
|
||||
/// attachment: the picture is still one of the card's files and is still in the list, which is
|
||||
/// what keeps this row distinct from the Remove one sitting below it.
|
||||
public func removeHero() {
|
||||
guard isEditable, hero != nil else { return }
|
||||
setHeroFile?(nil)
|
||||
}
|
||||
|
||||
// MARK: - The hero rows' rules
|
||||
|
||||
/// Whether a row offers **Set as Hero** — the row is an image, the section can write, the file is
|
||||
/// actually in the listing, and the card's hero is not already this very file.
|
||||
///
|
||||
/// **Image-type only** (`PastedImage.isImageName`), because the key means a picture: offering the
|
||||
/// row on a `.zip` would let a user set a hero that can never draw, and 03's structural degrade
|
||||
/// would leave them with a key and no band and nothing to explain it.
|
||||
///
|
||||
/// **Absent, not disabled, on the current hero's row**: that row shows Remove Hero instead, which
|
||||
/// is the same slot saying the true thing. Everywhere else "Set as Hero" *replaces* whatever hero
|
||||
/// the card had — one hero per card, and a Remove-then-Set dance would be ceremony (see
|
||||
/// `BoardStore.setHero(_:onCard:on:)`).
|
||||
///
|
||||
/// A pure static for `moved`/`settle`'s reason: the menu's two branches become lines of test
|
||||
/// rather than a context menu somebody has to open.
|
||||
public nonisolated static func canSetHero(
|
||||
_ name: String,
|
||||
hero: String?,
|
||||
names: [String],
|
||||
isEditable: Bool
|
||||
) -> Bool {
|
||||
guard isEditable, names.contains(name), hero != name else { return false }
|
||||
return PastedImage.isImageName(name)
|
||||
}
|
||||
|
||||
/// Whether a row offers **Remove Hero** — it is the card's current hero, and the section can
|
||||
/// write. The image test is deliberately *not* repeated: a hero somebody hand-wrote to a
|
||||
/// non-image file is exactly the state this row exists to get out of.
|
||||
public nonisolated static func canRemoveHero(
|
||||
_ name: String,
|
||||
hero: String?,
|
||||
isEditable: Bool
|
||||
) -> Bool {
|
||||
isEditable && hero == name
|
||||
}
|
||||
|
||||
// MARK: - Row actions that are not writes
|
||||
|
||||
/// Double-click, Return, and the context menu's Open: the file's default app (05 ▸ Attachments).
|
||||
@@ -238,6 +304,78 @@ struct AddAttachmentCommand: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File ▸ Set as Hero / Remove Hero
|
||||
|
||||
/// **The attachment row's hero pair, as menu rows** — card window only, acting on the attachments
|
||||
/// section's *selected* row (11-command-nexus.md; 05-card-window.md ▸ Attachments; 03-board-ui.md
|
||||
/// § Card face ▸ Hero image).
|
||||
///
|
||||
/// ### Why the menu rows exist at all
|
||||
///
|
||||
/// 11's context-menu contract: "Every entry is a twin of a menu command, a fixed grammar key, or a
|
||||
/// configuration control — **no function's only home**". The row's pointer path is the context menu;
|
||||
/// these are its required twins, and they are also what makes the gesture keyboard-reachable in a
|
||||
/// section 05 went out of its way to make keyboard-native.
|
||||
///
|
||||
/// ### Two rows, not one row with two titles
|
||||
///
|
||||
/// **Titles are API** (04-interactions.md ▸ Configurable bindings) — a user's custom binding is
|
||||
/// stored against the title — so a single row that renamed itself would silently drop that binding
|
||||
/// every time the selection moved. Two rows is `Collapse Lane`/`Expand Lane`'s answer to the same
|
||||
/// shape, and for the same reason. In the *context* menu the two share one slot, because a context
|
||||
/// menu is built fresh per row and carries no bindings.
|
||||
///
|
||||
/// The subject is the **selected** row rather than a row under a pointer, which is what a menu-bar
|
||||
/// item can address at all — File ▸ Reveal in Finder's card-window scope reads the same selection.
|
||||
struct SetAsHeroCommand: View {
|
||||
|
||||
@FocusedValue(\.cardAttachments) private var attachments
|
||||
|
||||
/// The row's validation as a value a test can hold — `AddAttachmentCommand.isEnabled`'s shape,
|
||||
/// for its reason. It is `CardAttachments.canSetHero` applied to the selected row, so the menu
|
||||
/// row and the context row can never disagree about what "an image that is not already the hero"
|
||||
/// means.
|
||||
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
|
||||
guard let attachments, let selected = attachments.selected else { return false }
|
||||
return CardAttachments.canSetHero(
|
||||
selected,
|
||||
hero: attachments.hero,
|
||||
names: attachments.names,
|
||||
isEditable: attachments.isEditable
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button("Set as Hero") {
|
||||
guard let attachments, let selected = attachments.selected else { return }
|
||||
attachments.setAsHero(selected)
|
||||
}
|
||||
.disabled(!Self.isEnabled(attachments))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set as Hero's other direction — see it for why the pair is two rows.
|
||||
struct RemoveHeroCommand: View {
|
||||
|
||||
@FocusedValue(\.cardAttachments) private var attachments
|
||||
|
||||
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
|
||||
guard let attachments, let selected = attachments.selected else { return false }
|
||||
return CardAttachments.canRemoveHero(
|
||||
selected,
|
||||
hero: attachments.hero,
|
||||
isEditable: attachments.isEditable
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button("Remove Hero") {
|
||||
attachments?.removeHero()
|
||||
}
|
||||
.disabled(!Self.isEnabled(attachments))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The focused value
|
||||
|
||||
/// The focused card window's attachments section, beside `FocusedValues.cardBody` — see
|
||||
|
||||
@@ -159,6 +159,7 @@ struct CardAttachmentsSection: View {
|
||||
name: name,
|
||||
url: url,
|
||||
isSelected: isSelected,
|
||||
isHero: attachments.hero == name,
|
||||
isSectionFocused: isFocused,
|
||||
thumbnails: thumbnails,
|
||||
pointSize: pointSize,
|
||||
@@ -194,17 +195,48 @@ struct CardAttachmentsSection: View {
|
||||
.contextMenu { menu(for: name) }
|
||||
}
|
||||
|
||||
/// The attachment row's context menu — **Open, Reveal in Finder, Remove** (11-command-nexus.md ▸
|
||||
/// Context menus), twins of the focused section's grammar keys (Return / ⌫) and of File ▸ Reveal
|
||||
/// in Finder in its attachments-focused context. No new store method, no parallel
|
||||
/// implementation: every row here calls exactly what the keyboard calls.
|
||||
/// The attachment row's context menu — **Open, Reveal in Finder, the hero row, Remove**
|
||||
/// (11-command-nexus.md ▸ Context menus), twins of the focused section's grammar keys (Return /
|
||||
/// ⌫) and of File ▸ Reveal in Finder in its attachments-focused context. No new store method, no
|
||||
/// parallel implementation: every row here calls exactly what the keyboard calls.
|
||||
///
|
||||
/// It acts on **its own row**, not on the selection, which is what makes a right-click on an
|
||||
/// unselected row unambiguous without a select-first dance.
|
||||
///
|
||||
/// ### The hero row is one slot with two words
|
||||
///
|
||||
/// **"Set as Hero"** on any image row that is not already the hero, **"Remove Hero"** on the one
|
||||
/// that is, and nothing at all on a row that can be neither — a non-image file, or any row while
|
||||
/// the board is locked (03-board-ui.md § Card face ▸ Hero image; the rules themselves are
|
||||
/// `CardAttachments.canSetHero`/`canRemoveHero`, so the menu and the tests read one answer).
|
||||
///
|
||||
/// **Present-or-absent rather than enabled-or-disabled**, which is the opposite of Remove just
|
||||
/// below it — and the difference is what the row would *mean* greyed out. A disabled Remove says
|
||||
/// "this file cannot be removed right now", which is true and useful under the lock. A disabled
|
||||
/// "Set as Hero" on a `.zip` would say "this file could be the hero, but not now", which is not
|
||||
/// true and never will be. The lock case follows the row rather than splitting it: a menu whose
|
||||
/// hero slot appears and disappears by file type and *also* greys by lock would be two rules
|
||||
/// where the section has one.
|
||||
///
|
||||
/// It sits between the read-only pair and Remove, on the divider grammar the Board menu uses
|
||||
/// (`KanbanApp`): reads first, then the edit-shaped rows, with the destructive one last.
|
||||
@ViewBuilder
|
||||
private func menu(for name: String) -> some View {
|
||||
Button("Open") { attachments.open(name) }
|
||||
Button("Reveal in Finder") { attachments.reveal(name) }
|
||||
|
||||
if CardAttachments.canSetHero(
|
||||
name, hero: attachments.hero, names: names, isEditable: attachments.isEditable
|
||||
) {
|
||||
Divider()
|
||||
Button("Set as Hero") { attachments.setAsHero(name) }
|
||||
} else if CardAttachments.canRemoveHero(
|
||||
name, hero: attachments.hero, isEditable: attachments.isEditable
|
||||
) {
|
||||
Divider()
|
||||
Button("Remove Hero") { attachments.removeHero() }
|
||||
}
|
||||
|
||||
Divider()
|
||||
Button("Remove") { attachments.remove(name) }
|
||||
.disabled(!attachments.isEditable)
|
||||
@@ -249,6 +281,9 @@ private struct AttachmentRow: View {
|
||||
let name: String
|
||||
let url: URL?
|
||||
let isSelected: Bool
|
||||
/// Whether this row's file is the card's hero — spoken, and nothing more: the row draws no badge
|
||||
/// of its own, because the banner *is* the sighted signal and it is right there on the card face.
|
||||
let isHero: Bool
|
||||
let isSectionFocused: Bool
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
let pointSize: CGFloat
|
||||
@@ -279,6 +314,7 @@ private struct AttachmentRow: View {
|
||||
.help(name)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(name)
|
||||
.accessibilityValue(isHero ? AccessibilityPhrases.heroAttachment : "")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
.task(id: url?.path) {
|
||||
guard let slot, let url else { return }
|
||||
|
||||
Reference in New Issue
Block a user