Comments, phase 2 — the pane, the composer, and the inline session
The card window recomposes into three componentized panes (body, comments, attributes) with two mounts — beside or body-over-comments at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is one persisted app-wide bit, no content-derived auto-show; File ▸ Add Comment flips it on and focuses the composer. The thread renders author lines, edited markers, card-subset Markdown bodies, and read-only Quick Look chips under a count header with the sort- direction control. The composer edits comments/.draft/ on the slow cadence (blur, close, quit, ~30s interval), Escape only moves focus, ⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start bytes, close flushes. File drops within either authoring surface carve out of the window-wide card default into that surface's attachments/; paperclips cover the no-drag path. Close flush runs inline flush, then draft save, then the comments/.trash purge; open sweeps crash residue. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - CommentTextEditor
|
||||
|
||||
/// The text surface both **authoring** surfaces use: the composer, and an inline comment edit
|
||||
/// session (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// ### One editor for both, because they are one thing twice
|
||||
///
|
||||
/// 05 describes the composer as "an always-visible text area ('Add a comment…', Edit-mode Markdown
|
||||
/// highlighting)" and the inline session as "a body-edit session in miniature". Both are raw Markdown
|
||||
/// with the body editor's highlighting over it, both end on ⌘↩, both answer Escape, and both are
|
||||
/// where a dropped file lands for their own folder. What differs is entirely outside this view —
|
||||
/// which buffer the keystrokes go to, what ⌘↩ means, what Escape means — so all four arrive as
|
||||
/// closures and none of them is decided here.
|
||||
///
|
||||
/// ### The highlighting is the body editor's, exactly
|
||||
///
|
||||
/// `MarkdownHighlighter` emits ranges and never a string, so "the text is the raw Markdown, character
|
||||
/// for character — no hidden transforms, no smart substitutions" (05 ▸ Edit) holds here for free, and
|
||||
/// the same smart-substitution deregistrations are repeated below because a comment is as much the
|
||||
/// user's file as a body is.
|
||||
///
|
||||
/// ### It declines file drags, like the body editor
|
||||
///
|
||||
/// `acceptableDragTypes` drops the file types so AppKit's hit-test walks past the text view — which
|
||||
/// is what lets the SwiftUI drop target *around* this view take the drop (the composer's carve-out,
|
||||
/// `CommentDropCarveOut`) instead of `NSTextView` inserting a path into the user's Markdown. The
|
||||
/// mechanism is `CardBodyTextView`'s, verbatim; only the target above it differs.
|
||||
struct CommentTextEditor: NSViewRepresentable {
|
||||
|
||||
let text: String
|
||||
let isEditable: Bool
|
||||
/// Every keystroke — straight into the session, which owns the cadence.
|
||||
let onEdit: (String) -> Void
|
||||
/// ⌘↩ — Post for the composer, Save for an inline session (11-command-nexus.md's grammar table).
|
||||
let onCommandReturn: () -> Void
|
||||
/// Escape — "focus moves out, draft untouched" for the composer; Cancel for an inline session.
|
||||
let onEscape: () -> Void
|
||||
/// Focus left. The composer's first cadence moment ("composer blur"); nothing for an inline
|
||||
/// session, whose commit points are its two buttons.
|
||||
var onBlur: () -> Void = {}
|
||||
/// Bumped to ask for the keyboard — File ▸ Add Comment's second half, and an inline session
|
||||
/// opening. A counter rather than a flag: two requests in a row are two requests.
|
||||
var focusRequest: Int = 0
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
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 = CommentEditorTextView(frame: .zero, textContainer: container)
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = isEditable
|
||||
textView.isSelectable = true
|
||||
textView.isRichText = false
|
||||
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)
|
||||
// The body editor's list, and normative here for its reason: 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
|
||||
textView.allowsUndo = true
|
||||
textView.usesFindBar = true
|
||||
textView.isIncrementalSearchingEnabled = true
|
||||
|
||||
let padding = CardWindowMetrics.previewPadding(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
textView.textContainerInset = CGSize(width: padding, height: padding)
|
||||
|
||||
textView.onCommandReturn = onCommandReturn
|
||||
textView.onEscape = onEscape
|
||||
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.documentView = textView
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.autohidesScrollers = true
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.findBarPosition = .aboveContent
|
||||
|
||||
context.coordinator.textView = textView
|
||||
context.coordinator.onEdit = onEdit
|
||||
context.coordinator.onBlur = onBlur
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
let coordinator = context.coordinator
|
||||
coordinator.onEdit = onEdit
|
||||
coordinator.onBlur = onBlur
|
||||
|
||||
guard let textView = scrollView.documentView as? CommentEditorTextView else { return }
|
||||
textView.isEditable = isEditable
|
||||
textView.onCommandReturn = onCommandReturn
|
||||
textView.onEscape = onEscape
|
||||
|
||||
coordinator.show(text, in: textView, pointSize: CardWindowMetrics.bodyPointSize)
|
||||
|
||||
guard focusRequest != coordinator.servedFocusRequest else { return }
|
||||
coordinator.servedFocusRequest = focusRequest
|
||||
guard focusRequest > 0 else { return }
|
||||
// Deferred a turn: this runs inside a SwiftUI update, and making a view first responder
|
||||
// re-enters AppKit's responder machinery (`CardBodySurface.Coordinator.enter`'s rule).
|
||||
Task { @MainActor [weak textView] in
|
||||
guard let textView else { return }
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
|
||||
weak var textView: NSTextView?
|
||||
var onEdit: ((String) -> Void)?
|
||||
var onBlur: (() -> Void)?
|
||||
var servedFocusRequest = 0
|
||||
|
||||
/// Set while this coordinator is replacing the view's text, so the resulting change
|
||||
/// notification is not mistaken for typing.
|
||||
private var isSettingText = false
|
||||
|
||||
/// `CardBodySurface.Coordinator.show(_:in:pointSize:)`, unchanged and for its reason: the
|
||||
/// equality guard is load-bearing rather than an optimization, because this runs on every
|
||||
/// keystroke and replacing the storage with the string it already holds would collapse the
|
||||
/// selection and throw away the undo stack on every character typed.
|
||||
func show(_ text: String, in textView: NSTextView, pointSize: CGFloat) {
|
||||
guard let storage = textView.textStorage else { return }
|
||||
|
||||
if storage.string != text {
|
||||
let selected = textView.selectedRange()
|
||||
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)
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard !isSettingText, let textView = notification.object as? NSTextView else { return }
|
||||
onEdit?(textView.string)
|
||||
}
|
||||
|
||||
/// **Composer blur is a save** (05 ▸ The comments column, the first of the four cadence
|
||||
/// moments). For an inline session `onBlur` is empty: its commit points are Save and Cancel,
|
||||
/// and clicking away from it is neither.
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
onBlur?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The editor's text view
|
||||
|
||||
/// The authoring editor's text view, subclassed for the two keys 11-command-nexus.md's grammar table
|
||||
/// gives it and for the drag types it must not take.
|
||||
final class CommentEditorTextView: NSTextView {
|
||||
|
||||
var onCommandReturn: (() -> Void)?
|
||||
var onEscape: (() -> Void)?
|
||||
|
||||
/// **⌘↩** — "Post the draft … / end the edit session at its commit point"
|
||||
/// (11-command-nexus.md ▸ Fixed grammar keys). Intercepted before `super`, which would otherwise
|
||||
/// insert a newline: the chord is the gesture, not a decorated Return.
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let isReturn = event.keyCode == 36 || event.keyCode == 76
|
||||
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.function, .numericPad, .capsLock])
|
||||
if isReturn, modifiers == .command, let onCommandReturn {
|
||||
onCommandReturn()
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
/// **Escape.** What it *means* is the caller's — focus out for the composer (never a discard),
|
||||
/// Cancel for an inline session — which is why this only forwards. Intercepted before
|
||||
/// `NSTextView`'s own meaning for it (text completion); with the find bar up the bar is first
|
||||
/// responder and never reaches this.
|
||||
override func cancelOperation(_ sender: Any?) {
|
||||
guard let onEscape else {
|
||||
super.cancelOperation(sender)
|
||||
return
|
||||
}
|
||||
onEscape()
|
||||
}
|
||||
|
||||
/// **A file drop is never the editor's** — `CardBodyTextView`'s deregistration, here so the drop
|
||||
/// falls through to the authoring surface's own target and lands in *this* surface's
|
||||
/// `attachments/` (05 ▸ Attachments, the hover-target carve-out).
|
||||
override var acceptableDragTypes: [NSPasteboard.PasteboardType] {
|
||||
let fileTypes: Set<NSPasteboard.PasteboardType> = [
|
||||
.fileURL,
|
||||
NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
||||
]
|
||||
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user