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
534 lines
29 KiB
Swift
534 lines
29 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - CardBodySurface
|
|
|
|
/// The body column's text surface: **one hosted `NSTextView` serving both modes** — the rendered
|
|
/// Preview and the raw-Markdown Edit editor.
|
|
///
|
|
/// ### Why AppKit, and not `Text(…).textSelection(.enabled)`
|
|
///
|
|
/// Four of Preview's settled rules are things a SwiftUI `Text` cannot do, and each of them is
|
|
/// normative rather than nice-to-have:
|
|
///
|
|
/// - **⌘F is find-in-text here** (05-card-window.md ▸ Preview; 11-command-nexus.md scopes ⌘F to
|
|
/// find-in-text in the card window). The standard find bar is `NSTextFinder` over a text view in
|
|
/// a scroll view; SwiftUI's text selection offers no find at all, and a hand-rolled search UI
|
|
/// would be a second, worse find bar in an app whose whole posture is to use the system's.
|
|
/// - **Clicking never edits, but a checkbox does something.** A text view already hit-tests
|
|
/// characters, already distinguishes a click from a selection drag, and already reports the one
|
|
/// it decided on to its delegate. That machinery is exactly the "selectable everywhere, live in
|
|
/// one place" grammar, and re-deriving it from a SwiftUI gesture over a `Text` would mean
|
|
/// re-deriving text selection.
|
|
/// - **Links open things.** `.link` attributes plus `textView(_:clickedOnLink:at:)` is the whole of
|
|
/// "external URLs open in the browser; relative links open the target with its default app".
|
|
/// - **Tables.** `NSTextTable`'s automatic layout *is* the browser sizing rule the design names,
|
|
/// and it is a TextKit 1 construct — which is why the stack below is built by hand rather than
|
|
/// taken from `NSTextView(frame:)`, whose modern default is TextKit 2.
|
|
///
|
|
/// ### One substrate, two modes
|
|
///
|
|
/// Preview and Edit differ in three things and nothing else: whether the view is editable, what it
|
|
/// is handed (a rendered attributed string, or the raw text under a highlighting pass), and which
|
|
/// key means "flip". Everything else — ⌘F, selection, copying, the find bar, the scroll position —
|
|
/// belongs to the substrate and is therefore identical in both, without either mode implementing it.
|
|
///
|
|
/// **One view rather than two representables**, deliberately: `CardBodyPresentation.findInText` holds
|
|
/// a closure over *this* text view, and two views swapping across a mode flip would race to own it —
|
|
/// ⌘F would work or not depending on the order SwiftUI happened to mount them in. One view has one
|
|
/// text view for the window's life, and the flip is a reconfiguration.
|
|
///
|
|
/// ### What the editor writes, and when
|
|
///
|
|
/// Nothing here writes to disk. The text view reports every change to `CardBodyEditSession`, which
|
|
/// owns the ~700 ms debounce, the three write gates and the flush; this file's whole responsibility
|
|
/// is that the buffer and the view agree, and that the view never has text replaced under the user's
|
|
/// cursor (the view half of dirty-buffer-wins — the session's half is `adopt(diskBody:)`).
|
|
struct CardBodySurface: NSViewRepresentable {
|
|
|
|
/// The text to show: `CardBodyEditSession.text`, which is the buffer in Edit and — because a
|
|
/// clean buffer follows disk — the card's body in Preview. One string for both modes is what
|
|
/// makes "the preview never lags the text that produced it" (05 ▸ Mode grammar) fall out rather
|
|
/// than need arranging.
|
|
let body: String
|
|
let mode: CardBodyMode
|
|
/// The card's own folder: what relative images and links resolve against.
|
|
let cardFolder: URL?
|
|
/// The window's body handle — this view fills in its `findInText`.
|
|
let presentation: CardBodyPresentation
|
|
/// The buffer this surface edits. Keystrokes go in through `edited(_:)`; nothing else here
|
|
/// touches it.
|
|
let session: CardBodyEditSession
|
|
/// Whether a checkbox click may write. `false` under the read-only lock, where "the controls
|
|
/// disable in place" (05 ▸ Preview; 02-architecture.md § the lock's scope).
|
|
let isTaskToggleEnabled: Bool
|
|
/// Byte offset and the state the user saw — straight through to `BoardStore.toggleTaskMarker`.
|
|
let onToggleTask: (Int, Bool) -> Void
|
|
/// Whether this surface scrolls on its own — `true`, the default, for every mount and mode
|
|
/// today. `false` is the stacked mount's continuous Preview arrangement
|
|
/// (`CardWindowView.continuousStackedContent`), where this surface's rendered content is one
|
|
/// section of a single shared document scroll rather than its own scrolling region: the hosted
|
|
/// `NSScrollView` loses its scroller and elasticity (the outer `ScrollView` owns wheel/trackpad
|
|
/// scrolling instead) and `sizeThatFits(_:nsView:context:)` below reports the text's own height
|
|
/// for whatever width it is proposed, rather than the view answering nothing and falling back to
|
|
/// a frame nothing constrains.
|
|
var scrolls: Bool = true
|
|
|
|
/// The height a measurement pass lays out into while `scrolls` is `false` — tall enough that no
|
|
/// card body reaches it, finite so the arithmetic stays well-defined. `CommentBodyView`'s own
|
|
/// constant, applied one level down through the hosting scroll view rather than straight to the
|
|
/// represented view.
|
|
private static let embeddedLayoutCeiling: CGFloat = 100_000
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
// TextKit 1, explicitly: `NSTextView(frame:)` would give a TextKit 2 stack, in which
|
|
// `NSTextTable` does not lay out. Building the stack by hand is the supported way to ask
|
|
// for the older one, and it is the only reason this is not a one-line construction.
|
|
let storage = NSTextStorage()
|
|
let layoutManager = NSLayoutManager()
|
|
storage.addLayoutManager(layoutManager)
|
|
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
|
container.widthTracksTextView = true
|
|
layoutManager.addTextContainer(container)
|
|
|
|
let textView = CardBodyTextView(frame: .zero, textContainer: container)
|
|
textView.delegate = context.coordinator
|
|
textView.isEditable = false
|
|
textView.isSelectable = true
|
|
textView.isRichText = true
|
|
textView.drawsBackground = false
|
|
textView.isVerticallyResizable = true
|
|
textView.isHorizontallyResizable = false
|
|
textView.autoresizingMask = NSView.AutoresizingMask.width
|
|
textView.minSize = CGSize(width: 0, height: 0)
|
|
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
|
// Nothing about a card body is the app's to rewrite as the user reads it — or as they type
|
|
// it: "the text is the raw Markdown, character for character — no hidden transforms, no
|
|
// smart substitutions" (05 ▸ Edit) is exactly this list, and in Edit it is normative rather
|
|
// than merely tidy. A smart quote substituted into a fenced code block would be the app
|
|
// silently corrupting the user's file.
|
|
textView.isAutomaticLinkDetectionEnabled = false
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
textView.isAutomaticTextReplacementEnabled = false
|
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
|
textView.isAutomaticDataDetectionEnabled = false
|
|
textView.smartInsertDeleteEnabled = false
|
|
// ⌘Z is the editor's own undo (05 ▸ Edit). `allowsUndo` turns it on; the *session* scoping is
|
|
// the coordinator's `undoManager(for:)`, below.
|
|
textView.allowsUndo = true
|
|
// The renderer already coloured links and checkboxes; the only thing the text view should
|
|
// add is the pointer, so the two do not fight over the run's appearance.
|
|
let linkAttributes: [NSAttributedString.Key: Any] = [.cursor: NSCursor.pointingHand]
|
|
textView.linkTextAttributes = linkAttributes
|
|
textView.displaysLinkToolTips = true
|
|
textView.usesFindBar = true
|
|
textView.isIncrementalSearchingEnabled = true
|
|
|
|
let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
|
textView.textContainerInset = CGSize(width: gutter, height: gutter)
|
|
|
|
// The two fixed keys of the mode grammar, on the surface that owns the keyboard while they
|
|
// are pressed: "Return in Preview also enters Edit … Escape in Edit returns to Preview"
|
|
// (05 ▸ Mode grammar). They are the text view's rather than a SwiftUI `.onKeyPress` because
|
|
// the text view *is* the first responder in both modes — a key handler above it would only
|
|
// see what the editor declined to eat.
|
|
let presentation = presentation
|
|
textView.onReturnInPreview = { presentation.setMode(.edit) }
|
|
textView.onEscapeInEdit = { presentation.setMode(.preview) }
|
|
|
|
let scrollView = NSScrollView()
|
|
scrollView.documentView = textView
|
|
scrollView.hasVerticalScroller = true
|
|
scrollView.hasHorizontalScroller = false
|
|
scrollView.autohidesScrollers = true
|
|
scrollView.drawsBackground = false
|
|
scrollView.findBarPosition = .aboveContent
|
|
// **`scrolls == false` switches this scroll view off** — no scroller to grab, no elastic
|
|
// bounce to fight the outer `ScrollView`'s own wheel/trackpad scrolling, which is what
|
|
// "embedded in an outer scroll" (the continuous arrangement) actually means at the AppKit
|
|
// layer. `sizeThatFits` below is the other half: without it this view would still have
|
|
// nothing to report and would collapse to zero height inside an unbounded `ScrollView`
|
|
// proposal.
|
|
if !scrolls {
|
|
scrollView.hasVerticalScroller = false
|
|
scrollView.verticalScrollElasticity = .none
|
|
scrollView.horizontalScrollElasticity = .none
|
|
}
|
|
|
|
context.coordinator.textView = textView
|
|
context.coordinator.session = session
|
|
context.coordinator.onToggleTask = onToggleTask
|
|
context.coordinator.isTaskToggleEnabled = isTaskToggleEnabled
|
|
|
|
// Deferred one turn: `makeNSView` runs inside SwiftUI's update, and `presentation` is
|
|
// observed by a menu item (Edit ▸ Find), so writing to it here would be a mutation during
|
|
// an update of the very graph that reads it.
|
|
Task { @MainActor [weak textView] in
|
|
presentation.findInText = { [weak textView] in
|
|
guard let textView else { return }
|
|
textView.window?.makeFirstResponder(textView)
|
|
// `performTextFinderAction` takes its verb from the sender's `tag`, which is how
|
|
// the standard Edit ▸ Find menu item drives it; a menu item made for the purpose
|
|
// says the same thing from a closure.
|
|
let sender = NSMenuItem()
|
|
sender.tag = NSTextFinder.Action.showFindInterface.rawValue
|
|
textView.performTextFinderAction(sender)
|
|
}
|
|
}
|
|
|
|
return scrollView
|
|
}
|
|
|
|
/// **The intrinsic height, while `scrolls` is `false`.** `nil` otherwise — the default
|
|
/// (unimplemented) answer, which lets a scrolling instance keep taking whatever frame its
|
|
/// `.frame(maxHeight: .infinity)` modifier proposes exactly as it always has.
|
|
///
|
|
/// `CommentBodyView.sizeThatFits`'s own trick, one level down through the hosting scroll view:
|
|
/// the container tracks the text view's own frame width (`widthTracksTextView`), so forcing that
|
|
/// frame to the proposed width *is* how the width is proposed at all, and `ensureLayout` is not
|
|
/// optional — `usedRect` only means something once the glyphs are laid, and an unlaid container
|
|
/// answers a zero-height rect, which would collapse the whole body to nothing.
|
|
func sizeThatFits(_ proposal: ProposedViewSize, nsView scrollView: NSScrollView, context: Context) -> CGSize? {
|
|
guard !scrolls else { return nil }
|
|
guard let textView = scrollView.documentView as? NSTextView,
|
|
let container = textView.textContainer,
|
|
let layoutManager = textView.layoutManager
|
|
else { return nil }
|
|
guard let width = proposal.width, width > 0, width.isFinite else { return nil }
|
|
|
|
textView.frame = NSRect(x: 0, y: 0, width: width, height: Self.embeddedLayoutCeiling)
|
|
layoutManager.ensureLayout(for: container)
|
|
let usedHeight = layoutManager.usedRect(for: container).height
|
|
// `usedRect` is the text alone; the gutter inset on both edges is this surface's own, added
|
|
// back in rather than folded into the container width above (`textContainerInset` already
|
|
// does that subtraction for `widthTracksTextView`, so doing it twice here would double it).
|
|
let totalHeight = usedHeight + textView.textContainerInset.height * 2
|
|
return CGSize(width: width, height: totalHeight.rounded(.up))
|
|
}
|
|
|
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
|
let coordinator = context.coordinator
|
|
coordinator.onToggleTask = onToggleTask
|
|
coordinator.isTaskToggleEnabled = isTaskToggleEnabled
|
|
coordinator.session = session
|
|
|
|
guard let textView = scrollView.documentView as? CardBodyTextView else { return }
|
|
let pointSize = CardWindowMetrics.bodyPointSize
|
|
|
|
if coordinator.mode != mode {
|
|
coordinator.enter(mode, in: textView)
|
|
}
|
|
|
|
let key = Coordinator.RenderKey(body: body, mode: mode, cardFolder: cardFolder, pointSize: pointSize)
|
|
// **Rebuild only when the inputs changed.** SwiftUI re-runs this on every unrelated state
|
|
// change in the window; re-laying out the whole body each time would throw away the scroll
|
|
// position and the selection — the reader's place in a document they are reading.
|
|
guard coordinator.rendered != key else { return }
|
|
coordinator.rendered = key
|
|
|
|
switch mode {
|
|
case .preview:
|
|
let context = BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: cardFolder)
|
|
textView.textStorage?.setAttributedString(
|
|
BodyMarkupRenderer.attributedString(for: BodyMarkup.parse(body), context: context)
|
|
)
|
|
|
|
case .edit:
|
|
coordinator.show(body, in: textView, pointSize: pointSize)
|
|
}
|
|
}
|
|
|
|
// MARK: - Coordinator
|
|
|
|
/// The delegate, the render cache, and the editor's session-scoped undo.
|
|
@MainActor
|
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
|
|
|
/// What the text view currently shows, as the inputs that produced it.
|
|
struct RenderKey: Equatable {
|
|
let body: String
|
|
let mode: CardBodyMode
|
|
let cardFolder: URL?
|
|
let pointSize: CGFloat
|
|
}
|
|
|
|
weak var textView: NSTextView?
|
|
var rendered: RenderKey?
|
|
var session: CardBodyEditSession?
|
|
var onToggleTask: ((Int, Bool) -> Void)?
|
|
var isTaskToggleEnabled = true
|
|
|
|
/// Which mode the view is currently *configured* for — `nil` until the first update, which is
|
|
/// what makes the initial configuration a mode entry like any other (and is why a card that
|
|
/// opens straight into Edit gets the caret without a special case).
|
|
private(set) var mode: CardBodyMode?
|
|
|
|
/// **The editor's own undo manager, and the whole of "session-scoped"** (05 ▸ Edit: "⌘Z here
|
|
/// is the text view's own undo — session-scoped, ending when the editor loses focus or the
|
|
/// mode flips").
|
|
///
|
|
/// Without this the text view would use the *window's* undo manager, whose stack outlives
|
|
/// every mode flip and is shared with anything else in the window that registers an
|
|
/// undoable action — so ⌘Z after leaving Edit could reach back into text the user had
|
|
/// already committed. Owning one here makes the scoping structural: `removeAllActions()` at
|
|
/// the two moments 05 names is then an emptying of a stack nothing else can see.
|
|
private let editorUndoManager = UndoManager()
|
|
|
|
/// Set while this coordinator is replacing the view's text, so the resulting change
|
|
/// notification is not mistaken for typing.
|
|
private var isSettingText = false
|
|
|
|
// MARK: Mode
|
|
|
|
/// Reconfigures the view for a mode — the only place editability, the undo stack and first
|
|
/// responder change.
|
|
func enter(_ newMode: CardBodyMode, in textView: CardBodyTextView) {
|
|
mode = newMode
|
|
// The session's undo stack ends with the mode, per 05. Emptied on the way *in* as well
|
|
// as out, so an Edit session never opens on top of the previous one's actions.
|
|
editorUndoManager.removeAllActions()
|
|
|
|
switch newMode {
|
|
case .preview:
|
|
textView.isEditable = false
|
|
|
|
case .edit:
|
|
textView.isEditable = true
|
|
textView.typingAttributes = MarkdownHighlighter.baseAttributes(
|
|
pointSize: CardWindowMetrics.bodyPointSize
|
|
)
|
|
// "**Empty body opens in Edit** with the cursor ready" (05 ▸ Mode grammar) — and the
|
|
// same courtesy for a deliberate ⌘E, which is a request to type. Deferred a turn:
|
|
// this runs inside a SwiftUI update, and making a view first responder re-enters
|
|
// AppKit's responder machinery.
|
|
Task { @MainActor [weak textView] in
|
|
guard let textView, textView.isEditable else { return }
|
|
textView.window?.makeFirstResponder(textView)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Puts `text` in the editor and highlights it — **never replacing what the user is looking
|
|
/// at unless it actually differs**.
|
|
///
|
|
/// The equality guard is load-bearing rather than an optimization: this runs on every
|
|
/// keystroke (the buffer changed, so SwiftUI re-ran the update), and replacing the storage
|
|
/// with the string it already holds would collapse the selection, scroll the view, and throw
|
|
/// away the undo stack — on every character typed.
|
|
func show(_ text: String, in textView: CardBodyTextView, pointSize: CGFloat) {
|
|
guard let storage = textView.textStorage else { return }
|
|
|
|
if storage.string != text {
|
|
// A foreign edit arriving under a *clean* buffer, or the first fill of the editor.
|
|
// The selection is preserved where it still fits; a caret past the new end clamps
|
|
// rather than disappearing.
|
|
let selected = textView.selectedRange()
|
|
// The undo stack described text that no longer exists — an agent or a hand edit
|
|
// replaced it — and ⌘Z restoring a run of it would be this app inventing a merge.
|
|
editorUndoManager.removeAllActions()
|
|
isSettingText = true
|
|
storage.setAttributedString(NSAttributedString(
|
|
string: text,
|
|
attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize)
|
|
))
|
|
isSettingText = false
|
|
let length = (text as NSString).length
|
|
textView.setSelectedRange(NSRange(
|
|
location: min(selected.location, length),
|
|
length: min(selected.length, max(0, length - min(selected.location, length)))
|
|
))
|
|
}
|
|
|
|
MarkdownHighlighter.highlight(storage, pointSize: pointSize)
|
|
textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize)
|
|
}
|
|
|
|
// MARK: NSTextViewDelegate
|
|
|
|
/// The editor's undo manager — see `editorUndoManager`.
|
|
func undoManager(for view: NSTextView) -> UndoManager? {
|
|
editorUndoManager
|
|
}
|
|
|
|
/// Every keystroke, straight into the buffer. The session decides what that costs: a
|
|
/// restarted debounce, or a cancelled one when the change happened to restore the file's own
|
|
/// text.
|
|
func textDidChange(_ notification: Notification) {
|
|
guard !isSettingText, mode == .edit, let textView = notification.object as? NSTextView else { return }
|
|
session?.edited(textView.string)
|
|
}
|
|
|
|
/// Focus leaving the editor ends the undo session (05 ▸ Edit), and is *not* a save: the
|
|
/// debounce is still running and will land on its own, which is what keeps clicking into the
|
|
/// sidebar from being a commit point the design never named.
|
|
func textDidEndEditing(_ notification: Notification) {
|
|
editorUndoManager.removeAllActions()
|
|
}
|
|
|
|
/// The click grammar, in one method: a checkbox writes, anything else opens, and the return
|
|
/// value is always `true` so the text view never falls back to its own link handling.
|
|
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
|
guard let url = Self.url(from: link) else { return false }
|
|
|
|
if let task = CardBodyLink.parseTask(url) {
|
|
// Disabled in place under the read-only lock: the click is swallowed rather than
|
|
// attempted, because a write that would be refused should not post a banner the
|
|
// standing lock row already explains.
|
|
guard isTaskToggleEnabled else { return true }
|
|
onToggleTask?(task.offset, task.isChecked)
|
|
return true
|
|
}
|
|
|
|
// External URLs go to the browser and relative ones — already resolved to file URLs by
|
|
// the renderer — go to their default app. `NSWorkspace.open` is both of those sentences
|
|
// (05 ▸ Preview ▸ Links).
|
|
NSWorkspace.shared.open(url)
|
|
return true
|
|
}
|
|
|
|
private static func url(from link: Any) -> URL? {
|
|
switch link {
|
|
case let url as URL: url
|
|
case let string as String: URL(string: string)
|
|
default: nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CardBodyTextView
|
|
|
|
/// The body surface's text view, subclassed for exactly two keys.
|
|
///
|
|
/// Return in Preview and Escape in Edit are **fixed grammar, not menu items** (05-card-window.md ▸
|
|
/// Mode grammar: "Return in Preview also enters Edit — the board's edit key applied to the body;
|
|
/// fixed grammar like the board's Return, not a menu item"), so they have to be intercepted where
|
|
/// the keyboard actually is. Both are guarded by editability, which is the mode: a Return in Edit is
|
|
/// a newline like any other, and an Escape in Preview means nothing here.
|
|
final class CardBodyTextView: NSTextView {
|
|
|
|
var onReturnInPreview: (() -> Void)?
|
|
var onEscapeInEdit: (() -> Void)?
|
|
|
|
/// Preview is not editable, so AppKit would send this nowhere — the mode's own key handling has
|
|
/// to come before `super`, which for a read-only text view merely beeps.
|
|
override func keyDown(with event: NSEvent) {
|
|
let isPlainReturn = event.keyCode == 36 || event.keyCode == 76
|
|
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
.subtracting([.function, .numericPad, .capsLock])
|
|
if !isEditable, isPlainReturn, modifiers.isEmpty {
|
|
onReturnInPreview?()
|
|
return
|
|
}
|
|
super.keyDown(with: event)
|
|
}
|
|
|
|
/// Escape. Intercepted before `NSTextView`'s own meaning for it (text completion), and only
|
|
/// while editing — with the find bar up the bar is first responder and never reaches this.
|
|
override func cancelOperation(_ sender: Any?) {
|
|
guard isEditable, let onEscapeInEdit else {
|
|
super.cancelOperation(sender)
|
|
return
|
|
}
|
|
onEscapeInEdit()
|
|
}
|
|
|
|
/// **A file drop is never the editor's** (05-card-window.md ▸ Attachments, settled: "file drops
|
|
/// import as attachments anywhere in the window — Edit mode included, the text editor never
|
|
/// intercepts a file drop; dragged *text* lands in the Edit editor at the caret").
|
|
///
|
|
/// An editable, rich `NSTextView` would otherwise happily take a dragged file and turn it into a
|
|
/// path or an attachment cell inside the user's Markdown. Dropping the *file* types from what
|
|
/// this view accepts lets that drag fall through to the window, which is where the attachment
|
|
/// import belongs. Every text type — a plain-text drag, a URL dragged out of a browser — is left
|
|
/// exactly as AppKit offers it, so the other half of the rule is the default behaviour rather
|
|
/// than a re-implementation of it.
|
|
///
|
|
// m6-card-attachments: the window-level drop surface that catches what this declines.
|
|
override var acceptableDragTypes: [NSPasteboard.PasteboardType] {
|
|
let fileTypes: Set<NSPasteboard.PasteboardType> = [
|
|
.fileURL,
|
|
// The Carbon-era name AppKit still puts on a Finder drag alongside the modern one.
|
|
NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
|
]
|
|
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
|
|
}
|
|
|
|
// MARK: The paste yield
|
|
|
|
/// **A pasteboard this editor cannot read is never the editor's either** — the drop rule above,
|
|
/// arrived at the keyboard (04-interactions.md ▸ Clipboard, the image-data and file-URL branches;
|
|
/// 05 ▸ Attachments makes the *window* answer ⌘V with an attachment import).
|
|
///
|
|
/// The focused-editor rule says a focused text surface wins ⌘V, and it still does: any pasteboard
|
|
/// carrying something this view can take — text, foremost — pastes into the text exactly as
|
|
/// before, image flavors riding beside it or not. But this view holds the keyboard from the
|
|
/// moment the window opens, and a *screenshot* pasteboard (image data, no text) is one it cannot
|
|
/// read at all: `NSTextView`'s own answer is a disabled menu row, which here means the window's
|
|
/// image branch sits one responder below the keyboard and can never be reached by it. "Wins"
|
|
/// must not mean "blocks what it cannot take" — so a paste this editor has no reading of is
|
|
/// passed to the responder behind it, and the standard validation walks the same path.
|
|
///
|
|
/// The yield is by *capability*, not by content kind, for the image branch — `readablePasteboardTypes`
|
|
/// is AppKit's own statement of what this view would accept, so the expression cannot drift from
|
|
/// the paste it guards. In Preview the view is not editable and takes no paste, so there the
|
|
/// window's branch simply owns ⌘V outright.
|
|
///
|
|
/// **The file-URL branch needs one content check, and here is why.** `acceptableDragTypes` above
|
|
/// excludes `.fileURL` outright, but `readablePasteboardTypes`' own answer already omits it too —
|
|
/// `importsGraphics` is `false` on this view, so AppKit does not offer it. The leak is one level
|
|
/// up: `public.file-url` *conforms to* `public.url`, which this view legitimately does read (so a
|
|
/// dragged or pasted web link still lands as text), and `NSPasteboard.availableType(from:)`
|
|
/// matches by conformance, not exact type — so a Finder copy that also declares a generic URL
|
|
/// representation beside its file URL would still read as "text this editor takes" through sheer
|
|
/// ancestry, and the paste would never reach the window at all. `PastedImage.carriesFileURL` is
|
|
/// the one predicate this whole feature already classifies file-URL pasteboards with
|
|
/// (`ClipboardStore.fileURLPayload`); reusing it here rather than a second UTI walk is what keeps
|
|
/// this check in step with that one.
|
|
|
|
/// The pasteboard the yield reads — the general one in the app; tests hand in their own so the
|
|
/// suite never touches the machine's (`FakePasteboard`'s reason, one seam over).
|
|
var yieldPasteboard: NSPasteboard = .general
|
|
|
|
/// Whether this editor itself would take the current pasteboard.
|
|
private var takesPasteboardAsText: Bool {
|
|
guard isEditable else { return false }
|
|
// A file URL is never this editor's, whatever generic ancestor type rides beside it on the
|
|
// pasteboard (the doc block above) — the file-URL branch owns this paste instead.
|
|
guard !PastedImage.carriesFileURL((yieldPasteboard.types ?? []).map(\.rawValue)) else { return false }
|
|
return yieldPasteboard.availableType(from: readablePasteboardTypes) != nil
|
|
}
|
|
|
|
/// The responder behind this view that answers `paste:` — the card window's file or image branch
|
|
/// when either is armed (`cardWindowPaste`; SwiftUI's bridge responds only while the handler is
|
|
/// attached), and `nil` when nothing behind would take the paste either.
|
|
private var pasteYieldTarget: NSResponder? {
|
|
var responder = nextResponder
|
|
while let current = responder {
|
|
if current.responds(to: #selector(NSText.paste(_:))) { return current }
|
|
responder = current.nextResponder
|
|
}
|
|
return nil
|
|
}
|
|
|
|
override func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool {
|
|
if item.action == #selector(NSText.paste(_:)), !takesPasteboardAsText {
|
|
return pasteYieldTarget != nil
|
|
}
|
|
return super.validateUserInterfaceItem(item)
|
|
}
|
|
|
|
override func paste(_ sender: Any?) {
|
|
guard !takesPasteboardAsText, let target = pasteYieldTarget else {
|
|
super.paste(sender)
|
|
return
|
|
}
|
|
target.tryToPerform(#selector(NSText.paste(_:)), with: sender)
|
|
}
|
|
}
|