Preview, stacked (over/under): title, the rendered body and the comment thread now stack in one continuous document with one scroll, instead of the fixed ≈3:2 split with each pane keeping its own. Edit mode keeps the split unchanged (an editor needs a stable scroll of its own), and the beside mount is untouched. CardBodySurface gains a `scrolls` flag: false switches off the hosted NSScrollView's scroller and elasticity and reports the NSTextView's own height for the proposed width via `sizeThatFits`, the layout-manager height-fit trick CommentBodyView already uses one level up. CardCommentsPane gains an `embeddedProxy`: supplied, it renders the same header, find bar, rows and composer without wrapping them in a second ScrollView, driving scrollTo off the outer document's proxy instead of its own. CardWindowView composes the two behind a new pure predicate, CommentsMount.showsContinuousDocument(mode:), tested in CardCommentsLayoutTests. The continuous↔split swap within stacked mount is a genuine remount of the body pane (two independent scrolls can't become one shared scroll by reconfiguration) — the same accepted cost the raw-source outlet already takes elsewhere in this window. No new animation on that swap, matching this file's existing precedent (the raw-source swap and the beside↔stacked mount switch are both instant cuts today). Decisions recorded on the card's thread, flagged for owner review where they're user-visible. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
278 lines
14 KiB
Swift
278 lines
14 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
// MARK: - CardCommentsPane
|
|
|
|
/// The card window's **comments pane** — the middle of the three componentized panes
|
|
/// (05-card-window.md ▸ Composition, ▸ The comments column).
|
|
///
|
|
/// ### It does not know where it is mounted
|
|
///
|
|
/// Beside the body or under it, the pane is identical — "the panes are identical in both mounts" —
|
|
/// so nothing in this file asks. It fills the frame it is given, scrolls its own content, and the
|
|
/// arrangement is `CardWindowView`'s (`CommentsMount`). That is the componentization the 2026-07-29
|
|
/// re-composition asks for, stated as an absence: there is no layout parameter here to get wrong.
|
|
///
|
|
/// ### Header, thread, composer — and the composer is at the newest end
|
|
///
|
|
/// > The composer sits at the thread's newest end (bottom ascending, top descending) and the window
|
|
/// > opens scrolled to it — a thread opens where the conversation is happening.
|
|
///
|
|
/// Both halves come from one value (`CommentSortDirection.placesComposerFirst`), so the scroll target
|
|
/// and the composer's position cannot disagree — a window that opened at the wrong end would be wrong
|
|
/// only for the users who had flipped the sort, which is exactly the bug that ships.
|
|
struct CardCommentsPane: View {
|
|
|
|
let comments: CardComments
|
|
/// The **card**'s folder — what relative images and links in every comment resolve against.
|
|
let cardFolder: URL?
|
|
/// The window's thumbnail memory, shared with the sidebar's attachment rows so a file shown in
|
|
/// both places is rendered once.
|
|
let thumbnails: AttachmentThumbnailCache
|
|
/// **The proxy of an outer `ScrollViewReader` this pane's content already lives inside** — the
|
|
/// stacked mount's continuous Preview arrangement (`CardWindowView.continuousStackedContent`),
|
|
/// where title, body and the thread share one document scroll rather than this pane owning its
|
|
/// own. `nil`, the default, is every other mount and mode: this pane wraps its own content in a
|
|
/// `ScrollView`/`ScrollViewReader` exactly as it always has.
|
|
///
|
|
/// **Nothing about a comment row, the composer, the find bar or the header changes with this** —
|
|
/// the type's own doc above ("it does not know where it is mounted") extended one step further:
|
|
/// the one thing that does change is which `ScrollViewProxy` a scrollTo call reaches for, and
|
|
/// whether this view supplies the `ScrollView` those calls need a proxy over in the first place.
|
|
var embeddedProxy: ScrollViewProxy? = nil
|
|
|
|
/// **App-wide and persisted** (05 ▸ The comments column; 11-command-nexus.md files the header
|
|
/// control under Configuration controls). Read here rather than mirrored onto the window's handle
|
|
/// because there is exactly one of it and every open pane obeys it.
|
|
@AppStorage(AppPreferences.commentsNewestFirstKey) private var newestFirst = false
|
|
|
|
/// The composer's scroll anchor. A constant rather than a generated id because there is one
|
|
/// composer and two possible places for it.
|
|
private static let composerAnchor = "comments.composer"
|
|
|
|
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
|
private var direction: CommentSortDirection { CommentSortDirection(newestFirst: newestFirst) }
|
|
private var ordered: [Comment] { direction.apply(to: comments.thread.comments) }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
|
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
|
|
|
// **The find bar sits above the thread it searches** — `findBarPosition = .aboveContent`,
|
|
// which is where every other find in this window puts its bar (`CardBodySurface`).
|
|
if comments.find.isShowing {
|
|
CommentFindBar(find: comments.find)
|
|
.padding(.top, CardWindowMetrics.previewPadding(bodyPointSize: pointSize))
|
|
}
|
|
|
|
thread
|
|
}
|
|
// **`maxHeight` drops out while embedded.** The standalone mounts hand this pane a *fixed*
|
|
// remaining height to fill (the beside `HStack`'s column, the stacked split's own row under
|
|
// its `GeometryReader`), so `.infinity` there is exactly what claims all of it. Embedded, the
|
|
// outer document `ScrollView` proposes an *unbounded* height instead, and a pane that still
|
|
// claimed `.infinity` of an unbounded proposal would grow to its content's ideal height
|
|
// anyway — but stating `nil` here is the honest version of that rather than a coincidence
|
|
// this file would have to keep re-deriving by eye.
|
|
.frame(maxWidth: .infinity, maxHeight: embeddedProxy == nil ? .infinity : nil, alignment: .topLeading)
|
|
// 10-accessibility.md's container label for the pane ("Comments, N") — the count is the
|
|
// thread's, so the spoken container and the visible header can never disagree.
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(AccessibilityPhrases.commentsContainerLabel(count: comments.thread.comments.count))
|
|
// The find's model of the rendered thread, rebuilt only while the bar is up: the search runs
|
|
// over every comment, mounted or not (`CommentThreadFind`), and building it costs a render
|
|
// pass the pane has no reason to spend on a window nobody is searching.
|
|
.onChange(of: FindThreadKey(isShowing: comments.find.isShowing, thread: comments.thread), initial: true) { _, key in
|
|
guard key.isShowing else { return }
|
|
comments.find.setThread(key.thread.comments, pointSize: pointSize)
|
|
}
|
|
// **A find cannot outlive the surface it searches.** The pane unmounts on View ▸ Show Comments
|
|
// and on the raw-source swap, and a session left showing would keep ⌘F and ⌘G pointed at a bar
|
|
// nobody can see (`CardWindowFind.route`'s open-bar clause).
|
|
.onDisappear { comments.find.dismiss() }
|
|
}
|
|
|
|
/// The pair the find's rebuild is keyed on. A value rather than two `onChange`s so the bar opening
|
|
/// and the thread changing take the same path, and so the rebuild cannot run twice for one update.
|
|
private struct FindThreadKey: Equatable {
|
|
let isShowing: Bool
|
|
let thread: CommentThread
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
/// "Comments · 3" with the sort control beside it — the sidebar's own small-caps section header,
|
|
/// shared rather than restated so the pane and the sidebar read as one window (05: "the section
|
|
/// header carries the count and the sort-direction control").
|
|
private var header: some View {
|
|
CardSidebarSectionHeader(title: CommentsHeader.title(count: comments.thread.comments.count)) {
|
|
sortControl
|
|
}
|
|
}
|
|
|
|
/// The sort-direction control — **Tab-reachable beside the count** (11-command-nexus.md ▸
|
|
/// Configuration controls).
|
|
///
|
|
/// A button rather than a segmented picker: there are two states and the second one is the
|
|
/// reverse of the first, so a toggle whose glyph says which way the thread currently runs is the
|
|
/// smaller thing that says the same. Its help text and its accessibility label are the same
|
|
/// string (`CommentSortDirection.controlLabel`) — one label, two readers.
|
|
private var sortControl: some View {
|
|
Button {
|
|
newestFirst.toggle()
|
|
} label: {
|
|
Image(systemName: direction == .ascending ? "arrow.down" : "arrow.up")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(direction.controlLabel)
|
|
.accessibilityLabel(AccessibilityPhrases.commentSortLabel)
|
|
.accessibilityValue(direction.controlLabel)
|
|
}
|
|
|
|
// MARK: - The thread
|
|
|
|
/// The rows, the composer, and their own `ScrollView` — or, embedded, the same rows and composer
|
|
/// without one, since `embeddedProxy`'s owner already supplies the scroll they sit inside.
|
|
@ViewBuilder
|
|
private var thread: some View {
|
|
if let embeddedProxy {
|
|
threadRows(proxy: embeddedProxy)
|
|
} else {
|
|
ScrollViewReader { proxy in
|
|
ScrollView(.vertical) {
|
|
threadRows(proxy: proxy)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The rows and the composer, at whichever end `direction` puts it — the one place either is
|
|
/// laid out, standalone or embedded. `proxy` is this pane's own when standalone and the outer
|
|
/// document's when embedded; the scrollTo calls below do not know which.
|
|
@ViewBuilder
|
|
private func threadRows(proxy: ScrollViewProxy) -> some View {
|
|
LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) {
|
|
if direction.placesComposerFirst {
|
|
composer.id(Self.composerAnchor)
|
|
}
|
|
ForEach(ordered) { comment in
|
|
CommentRowView(
|
|
comment: comment,
|
|
comments: comments,
|
|
cardFolder: cardFolder,
|
|
thumbnails: thumbnails
|
|
)
|
|
.id(comment.id)
|
|
}
|
|
if !direction.placesComposerFirst {
|
|
composer.id(Self.composerAnchor)
|
|
}
|
|
}
|
|
.padding(CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
// **The window opens scrolled to the composer** (05) — **standalone only**. Embedded, that
|
|
// would mean skipping past the title and the body to the thread's newest end on every open,
|
|
// which reads wrong for a single continuous document; the composer still gets the reader
|
|
// brought to it by an explicit request (`focusComposerRequests`, just below), only the
|
|
// *opening* auto-scroll is standalone-only. Deferred one turn rather than run inline:
|
|
// `scrollTo` needs the content laid out to have somewhere to scroll to, and a thread's rows
|
|
// measure their own rendered height (`CommentBodyView`).
|
|
.task {
|
|
guard embeddedProxy == nil else { return }
|
|
await Task.yield()
|
|
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
|
|
}
|
|
// File ▸ Add Comment focuses the composer — which is no use if the composer is off
|
|
// screen, so the same request scrolls to it. One request, both effects.
|
|
.onChange(of: comments.focusComposerRequests) { _, _ in
|
|
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
|
|
}
|
|
// **A find that steps off screen brings the reader with it** — the half of "it reads as one
|
|
// find" that the highlight alone cannot do. Scrolling to the comment rather than to the
|
|
// range is what a row-shaped thread allows: rows are the scroll targets
|
|
// (`ForEach(...).id(comment.id)`), and a comment is short enough that its top is the hit's
|
|
// neighbourhood.
|
|
.onChange(of: comments.find.currentMatch) { _, match in
|
|
guard let match else { return }
|
|
proxy.scrollTo(match.comment, anchor: .center)
|
|
}
|
|
}
|
|
|
|
// MARK: - The composer
|
|
|
|
/// **The composer edits `comments/.draft/`** — always visible, at the thread's newest end
|
|
/// (05 ▸ The comments column).
|
|
private var composer: some View {
|
|
CommentComposerView(comments: comments, thumbnails: thumbnails)
|
|
}
|
|
}
|
|
|
|
// MARK: - The authoring surfaces' drop carve-out
|
|
|
|
/// **A file dropped within an authoring surface's bounds lands in *that* surface's `attachments/`**
|
|
/// (05-card-window.md ▸ Attachments, ruled 2026-07-29 — the hover-target carve-out on the
|
|
/// window-wide card default).
|
|
///
|
|
/// The *arbitration* is SwiftUI's own innermost-target dispatch: this delegate is attached **inside**
|
|
/// `CardWindowDropDelegate`'s region, so a drag released over the composer or over an open inline
|
|
/// editor is offered here first and never reaches the window's card default.
|
|
///
|
|
/// The *destination* is `CommentDropCarveOut`, which is why this takes a hover rather than a folder:
|
|
/// the surface says what the pointer is over and the pure rule says where the files go, so the ruling
|
|
/// — including which authoring surface wins where they would ever overlap — is checkable without a
|
|
/// window and cannot drift from what the delegate actually does. A hover the rule resolves to the
|
|
/// **card** never reaches here at all (no authoring surface is under the pointer, so no authoring
|
|
/// surface has a drop target on screen), and this refuses it rather than guessing a folder.
|
|
///
|
|
/// Everything else is `CardWindowDropDelegate`'s, deliberately: the same payload predicate (files,
|
|
/// not folders, not text), the same read-only refusal, the same `.copy` badge, and the same
|
|
/// asynchronous URL load with the sandbox's security scope around it. Only the destination differs,
|
|
/// which is the entire point of the carve-out.
|
|
struct CommentAttachmentDropDelegate: DropDelegate {
|
|
|
|
let comments: CardComments
|
|
/// What the pointer is over, as this surface knows it — see `CommentDropCarveOut.Hover`.
|
|
let hover: CommentDropCarveOut.Hover
|
|
|
|
/// Where the rule says the files go, or `nil` for the window-wide card default.
|
|
private var target: CommentTarget? {
|
|
guard case let .comment(target) = CommentDropCarveOut.landing(for: hover) else { return nil }
|
|
return target
|
|
}
|
|
|
|
private var acceptsFileDrops: Bool { comments.isEditable && target != nil }
|
|
|
|
func validateDrop(info: DropInfo) -> Bool {
|
|
guard acceptsFileDrops else { return false }
|
|
return CardWindowDrop.accepts(
|
|
payloads: info.itemProviders(for: [.fileURL]).map(\.registeredTypeIdentifiers)
|
|
)
|
|
}
|
|
|
|
func dropUpdated(info: DropInfo) -> DropProposal? {
|
|
DropProposal(operation: validateDrop(info: info) ? .copy : .cancel)
|
|
}
|
|
|
|
func performDrop(info: DropInfo) -> Bool {
|
|
guard acceptsFileDrops, let target else { return false }
|
|
let providers = info.itemProviders(for: [.fileURL])
|
|
guard !providers.isEmpty else { return false }
|
|
|
|
let comments = comments
|
|
Task { @MainActor in
|
|
var urls: [URL] = []
|
|
for provider in providers {
|
|
if let url = await FileDropLoading.url(from: provider) { urls.append(url) }
|
|
}
|
|
guard !urls.isEmpty else { return }
|
|
comments.importFiles(urls, to: target)
|
|
}
|
|
return true
|
|
}
|
|
}
|