Kanban.xcodeproj must be regenerated (xcodegen generate) before building. CardWindowMetrics.commentsColumnWidth was a fixed figure between the body column and the comments pane in the side-by-side layout; it is now only the resting default. A new CommentsColumnDivider replaces the plain Divider() between the two panes with a real HStack element carrying a resize-left-right cursor and a drag gesture, clamped through a new pure seam, CardWindowMetrics.clampedCommentsColumnWidth — never narrower than the existing commentsMinimumWidth floor, never wide enough to push the body under its own bodyMinimumWidth. The drag tracks live in memory (commentsColumnWidthOverride) and writes AppPreferences.commentsColumnWidthKey exactly once, on release, mirroring LaneResizeSession's live-track/write-once split rather than hammering UserDefaults per tick. Persistence is app-wide via @AppStorage, matching showComments and commentsBesideBody — the pane's other two layout bits — rather than the per-card BoardRegistry.cardWindowFrames: this is "how the pane is arranged," the same kind of fact those two already are, not a per-card window geometry. Flagged on the card thread as a call worth owner review. Six new unit tests cover the clamp's two floors, the degenerate case where a container is too narrow for both, and its agreement with CardWindowMetrics.minimumSize at the window's own floor. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
422 lines
24 KiB
Swift
422 lines
24 KiB
Swift
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
// MARK: - CardWindowView
|
|
|
|
/// The card window's content: **three componentized panes** — a wide body pane leading, the comments
|
|
/// pane in the middle when it is shown, and the narrow attributes sidebar trailing
|
|
/// (05-card-window.md ▸ Composition, re-composed 2026-07-29).
|
|
///
|
|
/// ### The composition arranges; the panes do not know about each other
|
|
///
|
|
/// > each an independent component with its own scroll, arranged by the window's layout rather than
|
|
/// > wired to each other; componentization is the rule, so the comments pane mounts beside the body
|
|
/// > or below it (the layout option) without either pane knowing which.
|
|
///
|
|
/// That is enforced here by there being nothing to enforce: `CardCommentsPane` takes no layout
|
|
/// parameter and the body column takes none either. This view puts one of them in a frame; the
|
|
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
|
|
///
|
|
/// The sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width, and
|
|
/// the resize flex still goes to the body and never to the sidebar. **The comments column is no
|
|
/// longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the beside
|
|
/// mount, so the flex the body and the comments pane split is now the user's to move, within the two
|
|
/// floors `CardWindowMetrics.clampedCommentsColumnWidth` keeps either side from crossing.
|
|
///
|
|
/// ### What this milestone builds, and what it deliberately does not
|
|
///
|
|
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
|
|
/// renderings of what the loader already knows — the card's title and its created/modified line —
|
|
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`), with the
|
|
/// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that
|
|
/// reads or writes beyond that is later work and is marked where it lands:
|
|
///
|
|
/// - the title as an editable field (commit on Return / focus loss, Escape abandons).
|
|
///
|
|
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
|
|
/// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
|
|
/// underneath without the composition moving.
|
|
///
|
|
/// ### The width rule, in one line
|
|
///
|
|
/// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That
|
|
/// is the whole of "the window's resize flex goes to the body, never the sidebar" — no split view
|
|
/// between body and sidebar, no stored divider position there, nothing for a drag to disagree with.
|
|
///
|
|
/// **The comments column keeps its own, separate width rule** (added 2026-08-09,
|
|
/// `CommentsColumnDivider`): a real divider, a drag, and a stored width — the exception this
|
|
/// sentence used to have none of, and still does not have between the body and the *sidebar*.
|
|
///
|
|
/// ### Why the title no longer scrolls with the body
|
|
///
|
|
/// The body surface is a hosted `NSScrollView` (`CardBodySurface`), because ⌘F's find bar lives in
|
|
/// one — "Edit ▸ Find (⌘F) is find-in-text here … the standard find bar" (05 ▸ Preview). A scroll
|
|
/// view inside a scroll view is a scroll view that fights, so the column's header — the title and
|
|
/// its created/modified line — sits above the body's scroller rather than inside it. 05 fixes the
|
|
/// column's *order* ("Body column, top to bottom") and the columns' independent scrolling, and both
|
|
/// still hold; which of the two things scrolls the title away was never settled, and pinning the
|
|
/// card's name over its own body is the better reading of a window whose subtitle already follows it.
|
|
struct CardWindowView: View {
|
|
|
|
let card: Card
|
|
/// The board this card belongs to.
|
|
///
|
|
/// The one place in this window a whole store is handed to a view rather than a narrow seam, and
|
|
/// the sidebar is why: the Style section hosts the **shared** style editor, whose API is
|
|
/// store-shaped by design (it reads the target set's current values and writes through the one
|
|
/// `applyStyle` bracket every anchor shares), and the Actions section's Delete is the store's own
|
|
/// move-to-trash. Routing either through a closure of this window's own would be a second
|
|
/// card-styling or card-deleting path to keep in step with the first — exactly what "one
|
|
/// component, one behavior" and "exactly the ⌫ delete" forbid.
|
|
let store: BoardStore
|
|
/// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md ▸ Styling ▸
|
|
/// Controls) — app state, not board state, which is why it arrives beside the store rather than
|
|
/// on it.
|
|
let recents: StyleRecents
|
|
/// The card's folder on disk — what relative images and links in the body resolve against
|
|
/// (05 ▸ Preview). `nil` only where a caller has no board root to build it from.
|
|
let cardFolder: URL?
|
|
/// This window's body-column state: which mode it is in, and the find-bar hook.
|
|
let bodyPresentation: CardBodyPresentation
|
|
/// This window's Edit buffer. It holds the text **both** surfaces show: the editor writes into
|
|
/// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say —
|
|
/// which is exactly the point at which dirty-buffer-wins is decided.
|
|
let bodySession: CardBodyEditSession
|
|
/// This window's raw-source outlet. While it is active the two columns are gone entirely — see
|
|
/// `body`.
|
|
let rawSource: CardRawSourceSession
|
|
/// Whether a checkbox may write — `false` under the board's read-only lock.
|
|
let isEditable: Bool
|
|
/// This window's attachments section: the listing, the selection, and the two writes it starts
|
|
/// (05 ▸ Attachments).
|
|
let attachments: CardAttachments
|
|
/// This window's comments pane: the thread, the composer's draft buffer, and the one open inline
|
|
/// edit session (05 ▸ The comments column). It lives on the window's *session* so the close flush
|
|
/// can reach it, which is why it arrives here rather than being made here.
|
|
let comments: CardComments
|
|
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
|
|
let thumbnails: AttachmentThumbnailCache
|
|
/// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels). It arrives for exactly
|
|
/// one consumer — the Style section, the one sidebar anchor that *writes* — because a gesture
|
|
/// issued in this window registers on this window's stack. It lives on the window's session so
|
|
/// the close can fold it, which is why it arrives here rather than being made here.
|
|
let undo: CardWindowUndo
|
|
/// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole
|
|
/// window**"). `nil` only where a caller has no store to import through.
|
|
let fileDrop: CardWindowDropDelegate?
|
|
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
|
|
let onToggleTask: (Int, Bool) -> Void
|
|
|
|
/// **View ▸ Show Comments** and **View ▸ Comments Beside Body** — app-wide, persisted, read here
|
|
/// rather than passed in (05 ▸ The comments column; ▸ Composition).
|
|
///
|
|
/// `@AppStorage` because the two bits genuinely are app-wide: every open card window obeys the
|
|
/// same pair, so a window that took them as parameters would need something above it keeping
|
|
/// every window in step with a value that has exactly one instance. It is also what makes the
|
|
/// menu rows' checkmarks and these panes provably the same bit (`ShowCommentsCommand`).
|
|
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
|
|
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
|
|
|
|
/// **The comments column's user-set width**, in points — the divider's memory (05-card-window.md
|
|
/// ▸ The comments column, extended 2026-08-09). `@AppStorage` for the same reason its two
|
|
/// neighbours above are: every open card window's divider answers to one figure. `0` is the
|
|
/// "never dragged" reading (`AppPreferences.commentsColumnWidthKey`'s doc) — a real drag can
|
|
/// never land there, since `CardWindowMetrics.clampedCommentsColumnWidth`'s floor is always
|
|
/// positive.
|
|
@AppStorage(AppPreferences.commentsColumnWidthKey) private var storedCommentsColumnWidth: Double = 0
|
|
|
|
/// **The divider's in-flight width**, live only while a drag is running — `nil` the rest of the
|
|
/// time. `LaneResizeSession.liveWidth`'s same split applied to a much smaller session: the drag
|
|
/// tracks the cursor in memory on every tick (`CommentsColumnDivider.onChange`), and only the
|
|
/// *release* writes `storedCommentsColumnWidth` (`.onCommit`), so a flick that fires dozens of
|
|
/// events writes `UserDefaults` — and every sibling card window's divider — exactly once.
|
|
@State private var commentsColumnWidthOverride: CGFloat?
|
|
|
|
/// The body font's point size, read once per body evaluation: every measurement in this view —
|
|
/// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale
|
|
/// together when the system text size changes.
|
|
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
|
|
|
/// Where the comments pane sits, when it is shown at all.
|
|
private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) }
|
|
|
|
/// The comments column's width before this render's clamp: the live drag's own width while one is
|
|
/// running, the persisted figure once the divider has ever been dragged and released, and
|
|
/// `CardWindowMetrics.commentsColumnWidth`'s font-derived default before either has ever happened.
|
|
/// Unclamped on purpose: clamping needs the beside `HStack`'s live container width, which only the
|
|
/// `.beside` render branch has (`clampedCommentsColumnWidth(in:)`), so this stays the one place
|
|
/// that decides *which number* to clamp rather than also deciding *how far*.
|
|
private var commentsColumnWidth: CGFloat {
|
|
if let commentsColumnWidthOverride { return commentsColumnWidthOverride }
|
|
return storedCommentsColumnWidth > 0
|
|
? CGFloat(storedCommentsColumnWidth)
|
|
: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize)
|
|
}
|
|
|
|
/// `commentsColumnWidth` clamped against `containerWidth` — the beside `HStack`'s own width this
|
|
/// render (`CardWindowMetrics.clampedCommentsColumnWidth`). Re-derived every render rather than
|
|
/// written back into `storedCommentsColumnWidth`: a window too narrow for the persisted figure
|
|
/// (a smaller display, say) shows the clamped width without silently shrinking the preference a
|
|
/// wider window will want back.
|
|
private func clampedCommentsColumnWidth(in containerWidth: CGFloat) -> CGFloat {
|
|
CardWindowMetrics.clampedCommentsColumnWidth(
|
|
commentsColumnWidth,
|
|
bodyPointSize: bodyPointSize,
|
|
containerWidth: containerWidth
|
|
)
|
|
}
|
|
|
|
/// The two columns — **or the raw-source editor in place of both of them**.
|
|
///
|
|
/// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the
|
|
/// **entire content area — title, body, and sidebar —** for the literal on-disk `index.md`") and
|
|
/// what the rule underneath it requires: the same frontmatter is being edited as raw text, so a
|
|
/// sidebar still offering to restyle the card, or a title field still writing to `title`, would
|
|
/// be two editors racing for one file. Unmounting them is the only version of "they can't fight"
|
|
/// that cannot be got wrong later.
|
|
///
|
|
/// The cost is one thing and it is accepted: the body editor's scroll position and selection do
|
|
/// not survive a round trip through source mode, because its text view genuinely goes away. What
|
|
/// does survive is the buffer, which is the Edit session's, not the view's — and it was flushed
|
|
/// to disk on the way in regardless.
|
|
var body: some View {
|
|
content
|
|
// **The drop surface is the whole window** (05 ▸ Attachments), which is why it hangs
|
|
// here — outside the raw-source swap, so a file dropped while the source outlet is open
|
|
// still imports — rather than on the attachments section it fills. The body editor lets
|
|
// file drags through to this by declining the file types
|
|
// (`CardBodyTextView.acceptableDragTypes`); dragged *text* never matches `.fileURL` and
|
|
// so is never offered here at all, which is the other half of the payload split.
|
|
.modifier(WindowFileDrop(delegate: fileDrop))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if rawSource.isActive {
|
|
// **All three panes**, comments included: "Raw Source still swaps the entire content area
|
|
// — all panes, comments included; the raw outlet's rule is unchanged" (05 ▸ The comments
|
|
// column). The swap encloses the whole composition below rather than any one pane, which
|
|
// is what keeps that true as the composition grows.
|
|
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
|
|
} else {
|
|
HStack(spacing: 0) {
|
|
contentPanes
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
Divider()
|
|
|
|
sidebar
|
|
// Fixed, and the one place it comes from.
|
|
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.background(.background.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The body pane and the comments pane, in whichever of the two mounts is current — or the body
|
|
/// pane alone, when Show Comments is off.
|
|
///
|
|
/// **The thread stays visible through body Edit in either mount**, and needs no rule of its own:
|
|
/// Edit swaps the content of the body pane (`CardBodySurface`), which is *inside* the body column
|
|
/// here, so nothing about the composition changes when the mode flips. That is the sidebar's own
|
|
/// precedent, which 05 names when it states the rule.
|
|
@ViewBuilder
|
|
private var contentPanes: some View {
|
|
if showComments {
|
|
switch mount {
|
|
case .beside:
|
|
// The `GeometryReader` is new here for the divider's sake — a resizable column needs
|
|
// the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`),
|
|
// exactly the reason the stacked branch below already reaches for one.
|
|
GeometryReader { proxy in
|
|
HStack(spacing: 0) {
|
|
bodyColumn
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
CommentsColumnDivider(
|
|
bodyPointSize: bodyPointSize,
|
|
containerWidth: proxy.size.width,
|
|
currentWidth: clampedCommentsColumnWidth(in: proxy.size.width),
|
|
onChange: { commentsColumnWidthOverride = $0 },
|
|
onCommit: { width in
|
|
storedCommentsColumnWidth = Double(width)
|
|
// The persisted figure now says the same thing the override does —
|
|
// clearing it hands `commentsColumnWidth` back to one source of truth
|
|
// rather than two that happen to agree.
|
|
commentsColumnWidthOverride = nil
|
|
}
|
|
)
|
|
|
|
commentsPane
|
|
// **User-resizable since 2026-08-09** — the sidebar's "resize flex
|
|
// always goes to the body" (05 ▸ Composition) still holds between the
|
|
// body and the *sidebar*; the body and the *comments column* now split
|
|
// it by the divider's drag instead, clamped so neither pane's own floor
|
|
// gives way (`CardWindowMetrics.clampedCommentsColumnWidth`).
|
|
.frame(width: clampedCommentsColumnWidth(in: proxy.size.width))
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
|
|
case .stacked:
|
|
// The ≈3:2 split needs a height to divide, and a `GeometryReader` is the only way to
|
|
// have one — `layoutPriority` and flexible frames express *preferences*, and this is
|
|
// a ratio the design fixes. The body takes its share; the comments pane takes the
|
|
// remainder, so the divider between them can never leave a gap or overlap.
|
|
GeometryReader { proxy in
|
|
VStack(spacing: 0) {
|
|
bodyColumn
|
|
.frame(height: mount.bodyHeight(in: proxy.size.height))
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
|
|
Divider()
|
|
|
|
commentsPane
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
bodyColumn
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
|
|
private var commentsPane: some View {
|
|
CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails)
|
|
}
|
|
|
|
// MARK: - Body column
|
|
|
|
/// Title, the quiet created/modified line, then the body — 05's top-to-bottom order.
|
|
private var bodyColumn: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
|
|
// m6-card-body: the title *field* — large and borderless, committing to frontmatter
|
|
// on Return or focus loss, clearing to remove the `title` key, Escape abandoning to
|
|
// the on-disk title. Read-only here; the placeholder rendering is already final.
|
|
Text(card.title.value ?? "Untitled")
|
|
.font(.largeTitle)
|
|
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the
|
|
// same secondary treatment the face gives it.
|
|
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
|
.textSelection(.enabled)
|
|
|
|
if let dateLine {
|
|
Text(dateLine)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
|
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
|
|
|
CardBodySurface(
|
|
// The session's text, never `card.body` directly: a dirty buffer outranks the
|
|
// snapshot (05 ▸ Write rules) and a flushed one is ahead of it by a reload, so the
|
|
// buffer is the truer of the two in both modes — which is also how Preview shows the
|
|
// text that produced it the instant Edit is left.
|
|
body: bodySession.text,
|
|
mode: bodyPresentation.mode,
|
|
cardFolder: cardFolder,
|
|
presentation: bodyPresentation,
|
|
session: bodySession,
|
|
isTaskToggleEnabled: isEditable,
|
|
onToggleTask: onToggleTask
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
// **Dirty-buffer-wins, applied on every snapshot** (05 ▸ Write rules): the session takes
|
|
// disk's word for what the file says, and takes it into the editor only when the buffer has
|
|
// nothing unsaved. `initial: true` is also how the buffer is filled at all — a window opens
|
|
// by adopting its card's body.
|
|
.onChange(of: card.body, initial: true) { _, body in
|
|
bodySession.adopt(diskBody: body)
|
|
}
|
|
// **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its
|
|
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
|
|
// "once" — a later reload that empties the file must not drag a reader into Edit.
|
|
.task { bodyPresentation.openIfNeeded(body: card.body) }
|
|
}
|
|
|
|
/// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent**
|
|
/// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
|
|
///
|
|
/// The "by" segment renders only with the self-reported provenance stamp present
|
|
/// (01-storage-format.md), which is the point of showing it at all: provenance made visible with
|
|
/// no commit trail to read it from.
|
|
private var dateLine: String? {
|
|
var parts: [String] = []
|
|
if let created = card.created.value {
|
|
parts.append("Created \(Self.dateText(created))")
|
|
}
|
|
if let modified = card.modified.value {
|
|
parts.append("Modified \(Self.dateText(modified))")
|
|
}
|
|
if let by = card.modifiedBy.value, !by.isEmpty {
|
|
parts.append("by \(by)")
|
|
}
|
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
|
}
|
|
|
|
private static func dateText(_ date: Date) -> String {
|
|
date.formatted(date: .abbreviated, time: .shortened)
|
|
}
|
|
|
|
// MARK: - Attributes sidebar
|
|
|
|
/// The sidebar's sections, **in 05's settled order**: Attachments, Style, Details, Actions.
|
|
///
|
|
/// One of the four is conditional, and the condition is the section's own rather than a rule
|
|
/// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys
|
|
/// ("shown only when any exist"). Everything else in the stack is unconditional, so the
|
|
/// composition a user learns on one card is the composition they get on the next.
|
|
///
|
|
/// The History section that once sat between Details and Actions left with app-managed git
|
|
/// (strategy/01-git-excision.md, 2026-08-08); View ▸ History (`FutureCommands.swift`) is the only
|
|
/// surviving reservation of that slot, and it anticipates the foreign-change journal successor.
|
|
private var sidebar: some View {
|
|
ScrollView(.vertical) {
|
|
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
|
|
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
|
|
|
|
CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo)
|
|
|
|
// The snapshot's own document, not a re-read: the loader parsed this file, unknown
|
|
// keys and their order included, and `Card` has carried it since (`BoardModel`).
|
|
CardDetailsSection(rows: CardDetails.rows(of: card.document))
|
|
|
|
CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The window-wide drop
|
|
|
|
/// Attaches the whole-window file drop, or nothing at all.
|
|
///
|
|
/// A modifier rather than an `if` inside `body` because `.onDrop` has to be applied to the *same*
|
|
/// view identity in both cases: a window whose store arrives a turn after its view would otherwise
|
|
/// re-mount its entire content when the drop target appeared, throwing away the body's scroll
|
|
/// position for nothing.
|
|
private struct WindowFileDrop: ViewModifier {
|
|
|
|
let delegate: CardWindowDropDelegate?
|
|
|
|
func body(content: Content) -> some View {
|
|
if let delegate {
|
|
// `.fileURL` alone: a text drag never matches, so it is never offered here and falls to
|
|
// the Edit editor, where `NSTextView` inserts it at the caret (05 ▸ Attachments).
|
|
content.onDrop(of: [.fileURL], delegate: delegate)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|