Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,40 @@ NativeTextViewWrapper(
replacement (e.g. an autocomplete result); the engine consumes it
and clears the binding.

### Height Behavior

By default the editor scrolls internally within whatever height SwiftUI
gives it. Set `heightBehavior` to `.fitsContent` to make the editor grow
to fit its content and report that height to SwiftUI, so an enclosing
`ScrollView` scrolls the page instead:

```swift
ScrollView {
NativeTextViewWrapper(
text: $text,
configuration: .init(heightBehavior: .fitsContent)
)
}
```

- The editor reports `headerHeight + text content height` to SwiftUI;
no inner scroller appears.
- Typing additional lines grows the block; deleting lines shrinks it.
- An empty document shows one line of height.
- Scroll-wheel events pass through to the enclosing scroll view.
- Composes with `readingWidth`: the centered column is preserved and
height grows to the column's content height.
- A static scroll-away header's band is included in the reported height.
The collapse-on-scroll animation is not meaningful in `.fitsContent`
because there is no internal scroll offset to drive it.
- Switching `heightBehavior` at runtime is supported; the editor
reconfigures immediately.

**Trade-offs:** `.fitsContent` forces full-document layout so the total
height is known, forgoing TextKit-2 viewport virtualization. This is
fine for small-to-medium inline content; very large documents still work
but lay out in full.

### Reading Column

Give long documents a fixed-width, centered column — and let wide GFM
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ public struct MarkdownEditorConfiguration: Sendable {
/// Centered reading-column width; wide tables break out to full width. nil = full width (default).
public var readingWidth: CGFloat?
public var spellChecking: SpellCheckingPolicy
/// How the editor resolves its own height.
///
/// - `.scrolls` (default): the editor scrolls internally within whatever
/// height SwiftUI gives it. This is the historical behavior.
/// - `.fitsContent`: the editor grows to fit its content and reports that
/// height to SwiftUI, so an enclosing `ScrollView` scrolls the page
/// instead of a nested internal scroller. The editor re-reports its
/// height per keystroke as well as after async content changes (image
/// loads, font-size changes, header band resizes).
///
/// Switching at runtime is supported; the editor reconfigures immediately
/// (scroller visibility, overscroll, inflation, and intrinsic size all
/// update in the same SwiftUI update cycle).
///
/// - SeeAlso: ``HeightBehavior``
public var heightBehavior: HeightBehavior

public init(
theme: MarkdownEditorTheme = .default,
Expand All @@ -68,7 +84,8 @@ public struct MarkdownEditorConfiguration: Sendable {
scrollers: ScrollersPolicy = .default,
textInsets: TextInsets = .default,
readingWidth: CGFloat? = nil,
spellChecking: SpellCheckingPolicy = .default
spellChecking: SpellCheckingPolicy = .default,
heightBehavior: HeightBehavior = .scrolls
) {
self.theme = theme
self.services = services
Expand All @@ -90,6 +107,7 @@ public struct MarkdownEditorConfiguration: Sendable {
self.textInsets = textInsets
self.readingWidth = readingWidth
self.spellChecking = spellChecking
self.heightBehavior = heightBehavior
}

public static let `default` = MarkdownEditorConfiguration()
Expand Down Expand Up @@ -523,3 +541,76 @@ public struct SafeAreaInsets: Sendable {

public static let `default` = SafeAreaInsets()
}

// MARK: - Height behavior

extension MarkdownEditorConfiguration {
/// How the editor resolves its own height.
///
/// ## Usage
///
/// ```swift
/// // Inline editor inside a page scroll view:
/// ScrollView {
/// NativeTextViewWrapper(
/// text: $text,
/// configuration: .init(heightBehavior: .fitsContent)
/// )
/// }
/// ```
///
/// ## Behavior
///
/// In `.fitsContent` mode:
/// - The editor reports `headerHeight + text content height` to SwiftUI.
/// - Typing grows/shrinks the block per keystroke; SwiftUI re-lays-out.
/// - An empty document shows at least one body line of height.
/// - Scroll-wheel events pass through to the enclosing scroll view.
/// - Caret visibility propagates to the enclosing (page-level) scroll
/// view so editing at the bottom of a tall block keeps the caret
/// on-screen.
/// - Async content changes (image/LaTeX finishing layout, font-size
/// change) re-report size via `invalidateIntrinsicContentSize`.
/// - Switching between `.scrolls` and `.fitsContent` at runtime is
/// supported; the editor reconfigures immediately.
///
/// ## Composition
///
/// - **Reading column** (`readingWidth`): the centered fixed-width column
/// is preserved; height grows to the column's content height.
/// - **Scroll-away header**: a static header's band is included in the
/// reported height. The collapse-on-scroll animation is driven by the
/// inner scroll offset, which is always zero in `.fitsContent`, so the
/// collapse never triggers. Combining a collapsing header with
/// `.fitsContent` is allowed but the collapse behavior is not meaningful.
///
/// ## Trade-offs
///
/// `.fitsContent` forces full-document layout so the total height is known.
/// For small-to-medium documents this is fine; for very large documents it
/// forgoes TextKit-2 viewport virtualization.
public enum HeightBehavior: Sendable {
/// The editor scrolls internally within the height SwiftUI gives it.
/// This is the historical behavior and the default.
case scrolls

/// The editor grows to fit its content and reports that height back to
/// SwiftUI, so an enclosing scroll view / page scrolls instead of a
/// nested scroll view. Internal scrolling and bottom-overscroll slack
/// are disabled in this mode.
case fitsContent

/// Whether the vertical scroller should be shown for this height
/// behavior and scroller policy combination.
///
/// In `.fitsContent` the editor never scrolls internally, so the
/// vertical scroller is always hidden regardless of the policy.
/// In `.scrolls` the policy's `hasVerticalScroller` is respected.
public func wantsVerticalScroller(for scrollers: ScrollersPolicy) -> Bool {
switch self {
case .fitsContent: return false
case .scrolls: return scrollers.hasVerticalScroller
}
}
}
}
23 changes: 23 additions & 0 deletions Sources/MarkdownEngine/TextView/ClampedScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,43 @@
import AppKit

final class ClampedScrollView: NSScrollView {
/// When true, the scroll view has no scrollable range — the editor reports its
/// own height to SwiftUI and the enclosing scroll view owns paging.
var fitsContent: Bool = false

/// Saved at the start of every live-resize (including spurious one-click resizes triggered by edge-cursor clicks) so the position is restored when the resize ends. Without this, NSScrollView's default top-anchor-during-resize would jolt a bottom-anchored user back up by hundreds of points on a single edge click.
private var scrollYBeforeLiveResize: CGFloat?

override var intrinsicContentSize: NSSize {
guard fitsContent, let container = documentView as? NativeTextViewContainer else {
return super.intrinsicContentSize
}
return NSSize(width: NSView.noIntrinsicMetric, height: container.scrollableContentHeight)
}

override func scrollWheel(with event: NSEvent) {
if fitsContent {
// No scrollable range — forward to the responder chain so the
// enclosing (SwiftUI) scroll view receives the event. In the
// standard SwiftUI hosting layout nextResponder is the clip view's
// superview; if the hosting hierarchy ever differs, AppKit's
// default responder-chain traversal still routes the event up.
nextResponder?.scrollWheel(with: event)
return
}
super.scrollWheel(with: event)
clampToInsets()
}

override func viewWillStartLiveResize() {
super.viewWillStartLiveResize()
guard !fitsContent else { return }
scrollYBeforeLiveResize = contentView.bounds.origin.y
}

override func viewDidEndLiveResize() {
super.viewDidEndLiveResize()
guard !fitsContent else { return }
if let y = scrollYBeforeLiveResize {
contentView.scroll(to: NSPoint(x: contentView.bounds.origin.x, y: y))
reflectScrolledClipView(contentView)
Expand All @@ -33,6 +55,7 @@ final class ClampedScrollView: NSScrollView {
}

func clampToInsets() {
guard !fitsContent else { return }
guard let doc = documentView else { return }
let minY = -contentInsets.top
// Use the real content height (not the inflated frame) so small
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ extension NativeTextView {
visibleHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
// Overscroll is a scroll-comfort affordance; meaningless without internal scrolling.
guard configuration.heightBehavior == .scrolls else { return 0 }
let headerHeight = (superview as? NativeTextViewContainer)?.headerHeight ?? 0
let policy = BottomOverscrollPolicy(
overscrollPercent: overscrollPercent,
Expand Down Expand Up @@ -185,12 +187,18 @@ extension NativeTextView {

func applyManagedFrameSize(width: CGFloat) {
let contentHeight = max(ceil(baseContentHeight + activeBottomOverscroll), 0)
// The container stacks a header band ABOVE this text view, so the text view only
// needs to fill the viewport MINUS that band for the whole document view to fill
// the viewport on short docs (header + textView ≥ viewport).
let headerH = (superview as? NativeTextViewContainer)?.headerHeight ?? 0
let scrollViewHeight = max((enclosingScrollView?.contentView.bounds.height ?? 0) - headerH, 0)
let height = max(contentHeight, scrollViewHeight)
let height: CGFloat
switch configuration.heightBehavior {
case .scrolls:
// The container stacks a header band ABOVE this text view, so the text view only
// needs to fill the viewport MINUS that band for the whole document view to fill
// the viewport on short docs (header + textView ≥ viewport).
let headerH = (superview as? NativeTextViewContainer)?.headerHeight ?? 0
let scrollViewHeight = max((enclosingScrollView?.contentView.bounds.height ?? 0) - headerH, 0)
height = max(contentHeight, scrollViewHeight)
case .fitsContent:
height = contentHeight
}
// Reading column: the column keeps its fixed wrap width; its centered X is
// owned by `centerReadingColumn` (driven from the container's restack).
let targetWidth = configuration.readingWidth != nil ? readingColumnWidth : max(width, 0)
Expand All @@ -207,6 +215,12 @@ extension NativeTextView {
// Tell the container our height changed so it can re-stack (move us below the
// header) and size itself. Re-entrancy is guarded inside the container.
(superview as? NativeTextViewContainer)?.textViewDidResize()

// Nudge SwiftUI to re-query sizeThatFits when content height changes outside
// the text binding (e.g. image/LaTeX load, font-size change, header band).
if configuration.heightBehavior == .fitsContent {
enclosingScrollView?.invalidateIntrinsicContentSize()
}
}

/// Re-center the column by moving its X (not resizing it) so it stays smooth during live resize.
Expand Down Expand Up @@ -282,6 +296,16 @@ extension NativeTextView {
suppressAutoRevealOnce = false
return
}
if configuration.heightBehavior == .fitsContent {
// The inner scroll view has no scrollable range, so the default
// scrollRangeToVisible does nothing useful. Propagate the caret rect
// to the enclosing (page-level) scroll view so it keeps the caret
// on-screen. AppKit's scrollRectToVisible propagation can stall at
// a nested NSScrollView boundary, so we walk up explicitly.
super.scrollRangeToVisible(range)
propagateCaretRevealToEnclosingScroller(range: range)
return
}
// Only the reading column needs manual reveal; default keeps AppKit's native implementation.
guard configuration.readingWidth != nil else {
super.scrollRangeToVisible(range)
Expand Down Expand Up @@ -319,6 +343,41 @@ extension NativeTextView {
}
}

/// Walk the view hierarchy above the inner scroll view to find the
/// enclosing (page-level) scroller and ask it to reveal the caret rect.
/// Used in `.fitsContent` where the inner scroll view cannot scroll.
private func propagateCaretRevealToEnclosingScroller(range: NSRange) {
guard let innerScrollView = enclosingScrollView,
let tlm = textLayoutManager,
let start = tlm.textContentManager?.location(
tlm.documentRange.location, offsetBy: range.location
) else { return }
// Compute the caret rect in window coordinates so we can convert it
// into whichever enclosing scroller we find.
var caretRect: CGRect?
tlm.enumerateTextLayoutFragments(from: start, options: [.ensuresLayout]) { fragment in
caretRect = fragment.layoutFragmentFrame.offsetBy(dx: 0, dy: self.frame.origin.y)
return false
}
guard let rect = caretRect else { return }
// Convert from document-view space (container) to the inner scroll
// view's coordinate space, then to window, so we can convert into
// any ancestor we find.
let container = innerScrollView.documentView ?? self
let rectInWindow = container.convert(rect, to: nil)
// Walk up past the inner scroll view looking for a parent NSScrollView.
var view: NSView? = innerScrollView.superview
while let v = view {
if let outerScrollView = v as? NSScrollView, outerScrollView !== innerScrollView {
guard let outerDocView = outerScrollView.documentView else { return }
let rectInOuter = outerDocView.convert(rectInWindow, from: nil)
outerDocView.scrollToVisible(rectInOuter)
return
}
view = v.superview
}
}

/// Force TextKit 2 to lay out all fragments within the current visible rect.
func ensureVisibleLayout() {
guard let tlm = textLayoutManager else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ final class NativeTextViewContainer: NSView {
textView.shiftWideTableOverlays(byY: deltaY)
}
let viewportH = enclosingScrollView?.contentView.bounds.height ?? 0
let totalH = max(headerHeight + textView.frame.height, viewportH)
let stacked = headerHeight + textView.frame.height
let totalH = (textView.configuration.heightBehavior == .fitsContent) ? stacked
: max(stacked, viewportH)
if abs(frame.height - totalH) > 0.5 {
setFrameSize(NSSize(width: w, height: totalH))
}
Expand Down
Loading
Loading