Skip to content

Promote the scrolling header into a first-class, embedder-agnostic API #62

Description

@Nicolas-Py

Summary

The markdown editor should expose a supported way to render an embedder-supplied header view above the editable body — a region that the host app fills with arbitrary content, that the engine reserves space for, offsets the text below, keeps hit-testable, and (optionally) scrolls away or collapses.

A prototype exists on feature/inline-scrolling-header (#61), but it grew out of one specific consumer (Nodes' inline node inspector) and its shape, naming, and guarantees are still Nodes-flavored. This issue is to generalize it into a stable, documented API that stands on its own, independent of any single app.

Why this belongs in the engine, not the app

The header is not just "a view on top." To behave correctly it must participate in the engine's internals:

  • The body text must be shifted down by the reserved height (textContainerOrigin / content-height / scroll-range accounting).
  • Clicks, caret placement, selection, and link/coordinate mapping must all stay correct relative to that offset.
  • It must remain hit-testable and stable during scroll (we hit real AppKit compositing/layout issues here — see below).

An app cannot get these right by stacking a view over the editor from the outside; they were exactly the failure modes of the earlier overlay/injection attempts. The reservation + coordinate-space integration only works from inside the engine, so the capability should be a real engine feature with a clean contract.

Use cases (beyond Nodes)

A reusable header unlocks, for any embedder:

  • Document title / hero + metadata — styled H1 + author/date/byline above the body.
  • Frontmatter / properties panel — render YAML/frontmatter as a card (Obsidian/Notion-style properties).
  • Status & context banners — "Draft", "Read-only", "AI-generated", sync state, conflict warnings.
  • Breadcrumbs / file-path bar for navigation.
  • Cover image / hero media that scrolls away as you read.
  • A scroll-away table of contents or reading-progress strip.
  • Per-document toolbars / inspectors (the Nodes case — just one instance of the general pattern).

Proposed capability

  • Accept an arbitrary embedder view (SwiftUI hosted via NSHostingView, or a raw NSView).
  • Behaviors, ideally selectable:
    • scroll-away (moves with the content — current prototype), and later
    • pinned/sticky (stays at the top while the body scrolls under it).
  • Collapsible with animation, between a collapsed height and the content's intrinsic height.
  • Height is layout-driven (the embedder's own content size), with an embedder-provided collapsed height.
  • Plays correctly with safe-area / content insets.
  • Clean lifecycle: rebuild on document switch, teardown, no retain cycles.

Rough shape (illustrative, not final):

struct EditorHeader {
    var content: AnyView            // or an NSView factory
    var mode: Mode                  // .scrollAway (+ later .pinned)
    var collapsedHeight: CGFloat
    var isExpanded: Bool
}

// MarkdownEditorView(..., header: EditorHeader?)

Hard problems already surfaced (carry these into the API)

From building the prototype (#61), these must be part of the contract/tests, not rediscovered per-consumer:

  1. Reserved height must come from a stable source. Driving the body's top inset from the live, in-flight clip frame causes the body to slide under the header during scroll (autoresizing text view vs. Auto-Layout-pinned clip race). Drive it from the intended constant.
  2. Cross-tree compositing during scroll. A layer-backed header (e.g. NSHostingView) inside a non-layer-backed, transparent NSTextView desyncs at the header/body seam during live scroll — the body shows through the header's lower rows for a frame. Fix in the prototype was to layer-back the document view; this needs validation across TextKit2 caret/IME/selection/overlay drawing as a supported configuration.
  3. Caret/coordinate workarounds (NativeTextView+CaretWorkarounds) compute the insertion-indicator Y without adding the reserved top inset — needs auditing so a reserved header never mis-places the trailing-newline caret.
  4. Hit-testing: the header must live inside the text view's bounds so AppKit hit-tests it (out-of-bounds subviews draw but don't receive events).

Acceptance criteria

  • Public, documented API for an embedder header, with no Nodes-specific naming/assumptions.
  • Body coordinate mapping (click, caret, selection, links) correct with a non-zero reserved header.
  • Header is hit-testable and stable during scroll (no seam flicker), validated on the supported OS range.
  • Caret/IME/selection/find/overlay drawing verified under the required layer-backing.
  • Collapse/expand animation; document-switch rebuild; clean teardown.
  • Example in the package demonstrating a non-Nodes use case (e.g. title + frontmatter card).

Out of scope / follow-ups

  • Pinned/sticky mode (start with scroll-away).
  • Multiple stacked headers / footer regions.

Prototype: #61.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions