App-side chrome: new "Pointer Cursor" toggle (Settings -> Editor, outpost.pointerCursorEnabled, on by default) driving a shared .pointerCursorOnHover() view modifier (push/pop NSCursor.pointingHand, macOS-only, no-op on iOS) wired onto every clickable sidebar/list row: collection tree rows + disclosure chevron, flat collection list, document outline rows, collection-overview tab bar + document/search rows, global search results, command palette rows. In-document link hover: MarkdownEditorConfiguration gets a new pointerCursorOverLinksWhileEditing flag (default true). Read-only mode already showed a pointing hand over links unconditionally; editable mode never did (I-beam only) until now - applyReadOnlyCursor in NativeTextView+CursorRects.swift now applies the same over any .link range while editing too, gated by the flag. Wiki links already carry .link alongside their own custom .wikiLinkID, so they're covered with no extra work. Closes out the last item from the original preferences-wiring scope.
738 lines
29 KiB
Swift
738 lines
29 KiB
Swift
//
|
|
// MarkdownEditorConfiguration.swift
|
|
// MarkdownEngine
|
|
//
|
|
// Created by Luca Chen on 16.03.26.
|
|
//
|
|
// Centralized configuration for the Markdown editor engine.
|
|
//
|
|
// This struct exposes every spacing, sizing, and behavior knob that is
|
|
// shared across the engine. The defaults reproduce the historical
|
|
// Nodes-app behavior, so passing `.default` keeps existing rendering
|
|
// pixel-identical. Embedders that want a different look-and-feel can
|
|
// override individual fields without forking the engine.
|
|
//
|
|
|
|
import AppKit
|
|
import Foundation
|
|
|
|
// MARK: - Top-level Configuration
|
|
|
|
/// All tunable values for the Markdown editor engine grouped by concern.
|
|
///
|
|
/// The struct is deliberately flat-with-nested-groups: top level holds
|
|
/// orthogonal feature areas (markers, code blocks, lists, …), each group
|
|
/// owns the values that belong together. Default values are the production
|
|
/// defaults used by the Nodes app and have been chosen empirically.
|
|
public struct MarkdownEditorConfiguration: Sendable {
|
|
|
|
public var theme: MarkdownEditorTheme
|
|
public var services: MarkdownEditorServices
|
|
public var markers: MarkerStyle
|
|
public var codeBlock: CodeBlockStyle
|
|
public var inlineCode: InlineCodeStyle
|
|
public var lists: ListStyle
|
|
public var taskCheckbox: TaskCheckboxStyle
|
|
public var headings: HeadingStyle
|
|
public var imageEmbed: ImageEmbedStyle
|
|
public var blockLatex: BlockLatexStyle
|
|
public var inlineLatex: InlineLatexStyle
|
|
public var blockquote: BlockquoteStyle
|
|
public var link: LinkStyle
|
|
public var paragraph: ParagraphStyle
|
|
public var overscroll: OverscrollPolicy
|
|
public var dragSelection: DragSelectionPolicy
|
|
public var safeAreaInsets: SafeAreaInsets
|
|
public var scrollers: ScrollersPolicy
|
|
public var textInsets: TextInsets
|
|
/// Centered reading-column width; wide tables break out to full width. nil = full width (default).
|
|
public var readingWidth: CGFloat?
|
|
public var spellChecking: SpellCheckingPolicy
|
|
/// Smart quote/dash substitution while typing. Independent of `spellChecking`
|
|
/// — AppKit tracks these as separate `NSTextView` flags.
|
|
public var textSubstitution: TextSubstitutionPolicy
|
|
/// Inline predictive-text completion (the ghost-text suggestion AppKit
|
|
/// shows as you type, same feature as Notes/TextEdit). Mirrors
|
|
/// `NSTextView.isAutomaticTextCompletionEnabled`.
|
|
public var textCompletion: TextCompletionPolicy
|
|
/// System Writing Tools (proofread/rewrite/summarize). Mirrors
|
|
/// `NSTextView.writingToolsBehavior` — `.none` when disabled.
|
|
public var writingTools: WritingToolsPolicy
|
|
/// 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
|
|
/// Present the document as raw Markdown source: no syntax hiding, no
|
|
/// styling, no wiki-link display transform (`[[Name|UUID]]` shows verbatim).
|
|
/// Stays editable, but smart input (list continuation, auto-wrap, ⇧⇥) is off.
|
|
/// Runtime-switchable; a flip rebuilds immediately and drops the document's
|
|
/// undo stack (actions from the other mode would replay at stale ranges).
|
|
public var rawSourceMode: Bool
|
|
/// Opt-in constructs beyond pure markdown (e.g. `==highlight==`). Empty by
|
|
/// default: unregistered syntax stays literal text. Order defines match
|
|
/// precedence among extensions; built-in constructs always win first.
|
|
public var extensions: [any MarkdownExtension]
|
|
/// Let the caret and the I-beam take the ink of the extension span they sit
|
|
/// in, instead of `theme.bodyText` and the plain system pointer.
|
|
///
|
|
/// Off by default. It only matters for an extension that INVERTS its
|
|
/// content (dark ink on a light block), where both cursors would otherwise
|
|
/// be drawn in the block's own color and disappear inside it. An extension
|
|
/// whose `contentAttributes` set no foreground is unaffected either way —
|
|
/// so this stays the embedder's explicit decision rather than something the
|
|
/// engine infers from a color it happens to see.
|
|
public var cursorFollowsSpanInk: Bool
|
|
/// Show a pointing-hand cursor over links while editing (not just in
|
|
/// read-only mode, where it always shows regardless of this flag).
|
|
///
|
|
/// On by default. The embedder's "pointer cursor" preference maps
|
|
/// straight to this — off just means the I-beam stays over links like
|
|
/// any other text, since a link's edge zone still repositions the caret
|
|
/// for editing rather than navigating (see `clickedOnLink`).
|
|
public var pointerCursorOverLinksWhileEditing: Bool
|
|
|
|
public init(
|
|
theme: MarkdownEditorTheme = .default,
|
|
services: MarkdownEditorServices = .default,
|
|
markers: MarkerStyle = .default,
|
|
codeBlock: CodeBlockStyle = .default,
|
|
inlineCode: InlineCodeStyle = .default,
|
|
lists: ListStyle = .default,
|
|
taskCheckbox: TaskCheckboxStyle = .default,
|
|
headings: HeadingStyle = .default,
|
|
imageEmbed: ImageEmbedStyle = .default,
|
|
blockLatex: BlockLatexStyle = .default,
|
|
inlineLatex: InlineLatexStyle = .default,
|
|
blockquote: BlockquoteStyle = .default,
|
|
link: LinkStyle = .default,
|
|
paragraph: ParagraphStyle = .default,
|
|
overscroll: OverscrollPolicy = .default,
|
|
dragSelection: DragSelectionPolicy = .default,
|
|
safeAreaInsets: SafeAreaInsets = .default,
|
|
scrollers: ScrollersPolicy = .default,
|
|
textInsets: TextInsets = .default,
|
|
readingWidth: CGFloat? = nil,
|
|
spellChecking: SpellCheckingPolicy = .default,
|
|
textSubstitution: TextSubstitutionPolicy = .default,
|
|
textCompletion: TextCompletionPolicy = .default,
|
|
writingTools: WritingToolsPolicy = .default,
|
|
heightBehavior: HeightBehavior = .scrolls,
|
|
rawSourceMode: Bool = false,
|
|
extensions: [any MarkdownExtension] = [],
|
|
cursorFollowsSpanInk: Bool = false,
|
|
pointerCursorOverLinksWhileEditing: Bool = true
|
|
) {
|
|
self.theme = theme
|
|
self.services = services
|
|
self.markers = markers
|
|
self.codeBlock = codeBlock
|
|
self.inlineCode = inlineCode
|
|
self.lists = lists
|
|
self.taskCheckbox = taskCheckbox
|
|
self.headings = headings
|
|
self.imageEmbed = imageEmbed
|
|
self.blockLatex = blockLatex
|
|
self.inlineLatex = inlineLatex
|
|
self.blockquote = blockquote
|
|
self.link = link
|
|
self.paragraph = paragraph
|
|
self.overscroll = overscroll
|
|
self.dragSelection = dragSelection
|
|
self.safeAreaInsets = safeAreaInsets
|
|
self.scrollers = scrollers
|
|
self.textInsets = textInsets
|
|
self.readingWidth = readingWidth
|
|
self.spellChecking = spellChecking
|
|
self.textSubstitution = textSubstitution
|
|
self.textCompletion = textCompletion
|
|
self.writingTools = writingTools
|
|
self.heightBehavior = heightBehavior
|
|
self.rawSourceMode = rawSourceMode
|
|
self.extensions = extensions
|
|
self.cursorFollowsSpanInk = cursorFollowsSpanInk
|
|
self.pointerCursorOverLinksWhileEditing = pointerCursorOverLinksWhileEditing
|
|
}
|
|
|
|
public static let `default` = MarkdownEditorConfiguration()
|
|
}
|
|
|
|
// MARK: - Spell checking
|
|
|
|
/// Initial state for the three "Spelling and Grammar" toggles. Only consulted
|
|
/// at `makeNSView` time; afterwards the user's context-menu choices take
|
|
/// precedence and are surfaced via ``NativeTextViewWrapper/onSpellCheckingPolicyChanged``.
|
|
public struct SpellCheckingPolicy: Sendable {
|
|
/// Mirrors `NSTextView.isContinuousSpellCheckingEnabled`.
|
|
public var continuousSpellChecking: Bool
|
|
/// Mirrors `NSTextView.isGrammarCheckingEnabled`.
|
|
public var grammarChecking: Bool
|
|
/// Mirrors `NSTextView.isAutomaticSpellingCorrectionEnabled`.
|
|
public var automaticSpellingCorrection: Bool
|
|
|
|
public init(
|
|
continuousSpellChecking: Bool = true,
|
|
grammarChecking: Bool = true,
|
|
automaticSpellingCorrection: Bool = true
|
|
) {
|
|
self.continuousSpellChecking = continuousSpellChecking
|
|
self.grammarChecking = grammarChecking
|
|
self.automaticSpellingCorrection = automaticSpellingCorrection
|
|
}
|
|
|
|
public static let `default` = SpellCheckingPolicy()
|
|
}
|
|
|
|
// MARK: - Text substitution
|
|
|
|
/// Smart quote/dash substitution while typing. Mirrors the historical
|
|
/// hardcoded `NativeTextViewWrapper` behavior (quotes on, dashes off) as the
|
|
/// default, now exposed as a config knob instead of fixed AppKit calls.
|
|
public struct TextSubstitutionPolicy: Sendable {
|
|
/// Mirrors `NSTextView.isAutomaticQuoteSubstitutionEnabled`.
|
|
public var quoteSubstitution: Bool
|
|
/// Mirrors `NSTextView.isAutomaticDashSubstitutionEnabled`.
|
|
public var dashSubstitution: Bool
|
|
|
|
public init(
|
|
quoteSubstitution: Bool = true,
|
|
dashSubstitution: Bool = false
|
|
) {
|
|
self.quoteSubstitution = quoteSubstitution
|
|
self.dashSubstitution = dashSubstitution
|
|
}
|
|
|
|
public static let `default` = TextSubstitutionPolicy()
|
|
}
|
|
|
|
// MARK: - Text completion
|
|
|
|
/// Inline predictive-text completion while typing (ghost-text suggestion,
|
|
/// accepted with Tab/→ — same feature as Notes/TextEdit on macOS 14+).
|
|
public struct TextCompletionPolicy: Sendable {
|
|
/// Mirrors `NSTextView.isAutomaticTextCompletionEnabled`.
|
|
public var isEnabled: Bool
|
|
|
|
public init(isEnabled: Bool = true) {
|
|
self.isEnabled = isEnabled
|
|
}
|
|
|
|
public static let `default` = TextCompletionPolicy()
|
|
}
|
|
|
|
// MARK: - Writing Tools
|
|
|
|
/// System Writing Tools (proofread / rewrite / summarize / compose), backed
|
|
/// by Apple Intelligence where available.
|
|
public struct WritingToolsPolicy: Sendable {
|
|
/// When `false`, `NSTextView.writingToolsBehavior` is set to `.none`
|
|
/// instead of `.limited` — hides Writing Tools entirely for this view.
|
|
public var isEnabled: Bool
|
|
|
|
public init(isEnabled: Bool = true) {
|
|
self.isEnabled = isEnabled
|
|
}
|
|
|
|
public static let `default` = WritingToolsPolicy()
|
|
}
|
|
|
|
// MARK: - Scroll bars
|
|
|
|
/// Scroll bar visibility. Default: vertical only, autohide on.
|
|
public struct ScrollersPolicy: Sendable {
|
|
public var hasVerticalScroller: Bool
|
|
public var hasHorizontalScroller: Bool
|
|
public var autohidesScrollers: Bool
|
|
|
|
public init(
|
|
hasVerticalScroller: Bool = true,
|
|
hasHorizontalScroller: Bool = false,
|
|
autohidesScrollers: Bool = true
|
|
) {
|
|
self.hasVerticalScroller = hasVerticalScroller
|
|
self.hasHorizontalScroller = hasHorizontalScroller
|
|
self.autohidesScrollers = autohidesScrollers
|
|
}
|
|
|
|
public static let `default` = ScrollersPolicy()
|
|
/// No scrollers (use with a custom scroll overlay).
|
|
public static let hidden = ScrollersPolicy(hasVerticalScroller: false, hasHorizontalScroller: false)
|
|
/// Vertical only — same as `.default`.
|
|
public static let vertical = ScrollersPolicy(hasVerticalScroller: true, hasHorizontalScroller: false)
|
|
/// Both axes (code-heavy / wide content).
|
|
public static let both = ScrollersPolicy(hasVerticalScroller: true, hasHorizontalScroller: true)
|
|
/// Vertical, no auto-hide.
|
|
public static let alwaysVisible = ScrollersPolicy(hasVerticalScroller: true, autohidesScrollers: false)
|
|
}
|
|
|
|
// MARK: - Text insets
|
|
|
|
/// Margins inside the text view (`NSTextView.textContainerInset`). Scroll bar stays at the outer edge.
|
|
public struct TextInsets: Sendable {
|
|
public var horizontal: CGFloat
|
|
public var vertical: CGFloat
|
|
|
|
public init(horizontal: CGFloat = 0, vertical: CGFloat = 0) {
|
|
self.horizontal = horizontal
|
|
self.vertical = vertical
|
|
}
|
|
|
|
public static let `default` = TextInsets()
|
|
}
|
|
|
|
// MARK: - Marker visibility
|
|
|
|
/// How Markdown syntax markers (e.g. `**`, `*`, `$`) are visualized when
|
|
/// the cursor is not inside the corresponding token.
|
|
///
|
|
/// The engine's default approach is to keep markers in the text storage but
|
|
/// shrink them to a near-zero font size (`hiddenMarkerFontSize`). This avoids
|
|
/// any range translation between displayed and stored text — cursor movement,
|
|
/// find/replace, selection, and copy/paste all stay trivially correct.
|
|
/// The trade-off is a sub-pixel residue at extreme zoom levels.
|
|
public struct MarkerStyle: Sendable {
|
|
/// Font size used for "hidden" inline markers. Effectively invisible at
|
|
/// normal zoom while keeping displayed-range == stored-range.
|
|
public var hiddenMarkerFontSize: CGFloat
|
|
/// Alpha applied to inline-code's secondary marker color.
|
|
public var inlineCodeMarkerAlpha: CGFloat
|
|
/// Alpha applied to non-focused find matches when in-document search
|
|
/// highlights are visible. The focused match is drawn at full opacity.
|
|
public var findMatchHighlightAlpha: CGFloat
|
|
|
|
public init(
|
|
hiddenMarkerFontSize: CGFloat = 0.1,
|
|
inlineCodeMarkerAlpha: CGFloat = 0.5,
|
|
findMatchHighlightAlpha: CGFloat = 0.65
|
|
) {
|
|
self.hiddenMarkerFontSize = hiddenMarkerFontSize
|
|
self.inlineCodeMarkerAlpha = inlineCodeMarkerAlpha
|
|
self.findMatchHighlightAlpha = findMatchHighlightAlpha
|
|
}
|
|
|
|
public static let `default` = MarkerStyle()
|
|
}
|
|
|
|
// MARK: - Code blocks
|
|
|
|
/// Styling for fenced code blocks (```language ... ```).
|
|
public struct CodeBlockStyle: Sendable {
|
|
/// Code-block font size as a fraction of the document base font size.
|
|
public var fontSizeScale: CGFloat
|
|
/// Vertical paragraph spacing applied above and below the code block.
|
|
public var paragraphSpacing: CGFloat
|
|
/// Left/right indent (in points) so code blocks don't run into the gutter.
|
|
public var horizontalIndent: CGFloat
|
|
|
|
public init(
|
|
fontSizeScale: CGFloat = 0.85,
|
|
paragraphSpacing: CGFloat = 2.0,
|
|
horizontalIndent: CGFloat = 12.0
|
|
) {
|
|
self.fontSizeScale = fontSizeScale
|
|
self.paragraphSpacing = paragraphSpacing
|
|
self.horizontalIndent = horizontalIndent
|
|
}
|
|
|
|
public static let `default` = CodeBlockStyle()
|
|
}
|
|
|
|
// MARK: - Inline code
|
|
|
|
/// Styling for inline `` `code` `` spans.
|
|
public struct InlineCodeStyle: Sendable {
|
|
/// Inline-code reuses the code block font size scale by default.
|
|
public var fontSizeScale: CGFloat
|
|
|
|
public init(fontSizeScale: CGFloat = 0.85) {
|
|
self.fontSizeScale = fontSizeScale
|
|
}
|
|
|
|
public static let `default` = InlineCodeStyle()
|
|
}
|
|
|
|
// MARK: - Lists
|
|
|
|
/// Behavior toggles and metrics for ordered / unordered list editing.
|
|
public struct ListStyle: Sendable {
|
|
/// Master switch for list-related editing helpers (auto-continue,
|
|
/// auto-indent, marker conversion). When `false`, lists are still
|
|
/// rendered, but typing-time conveniences are skipped.
|
|
public var helpersEnabled: Bool
|
|
/// Master switch for auto-closing pairs `()`, `{}`, `[]` while typing.
|
|
public var autoClosePairsEnabled: Bool
|
|
/// Indent (in points) that one nesting level adds to the list item.
|
|
public var indentPerLevel: CGFloat
|
|
/// Maximum nesting level reachable by pressing Tab inside a list.
|
|
public var maximumNestingLevel: Int
|
|
/// Extra line height added on top of the default to give list items room.
|
|
public var extraLineHeight: CGFloat
|
|
|
|
public init(
|
|
helpersEnabled: Bool = true,
|
|
autoClosePairsEnabled: Bool = true,
|
|
indentPerLevel: CGFloat = 27.5,
|
|
maximumNestingLevel: Int = 3,
|
|
extraLineHeight: CGFloat = 2
|
|
) {
|
|
self.helpersEnabled = helpersEnabled
|
|
self.autoClosePairsEnabled = autoClosePairsEnabled
|
|
self.indentPerLevel = indentPerLevel
|
|
self.maximumNestingLevel = maximumNestingLevel
|
|
self.extraLineHeight = extraLineHeight
|
|
}
|
|
|
|
public static let `default` = ListStyle()
|
|
}
|
|
|
|
// MARK: - Task checkboxes
|
|
|
|
/// SF Symbol names used to draw task-list checkboxes (`- [ ]` / `- [x]`).
|
|
///
|
|
/// Any SF Symbol available on the deployment target can be substituted, for
|
|
/// example `"circle"` / `"checkmark.circle.fill"`. A name that doesn't
|
|
/// resolve falls back to the corresponding default symbol at draw time, so a
|
|
/// typo degrades to the stock look instead of drawing nothing. Tint colors
|
|
/// stay theme-driven (`MarkdownEditorTheme/mutedText` unchecked,
|
|
/// `MarkdownEditorTheme/bodyText` checked).
|
|
public struct TaskCheckboxStyle: Sendable {
|
|
/// SF Symbol drawn for an unchecked task item (`[ ]`).
|
|
public var uncheckedSymbolName: String
|
|
/// SF Symbol drawn for a checked task item (`[x]`).
|
|
public var checkedSymbolName: String
|
|
|
|
public init(
|
|
uncheckedSymbolName: String = "square",
|
|
checkedSymbolName: String = "checkmark.square.fill"
|
|
) {
|
|
self.uncheckedSymbolName = uncheckedSymbolName
|
|
self.checkedSymbolName = checkedSymbolName
|
|
}
|
|
|
|
public static let `default` = TaskCheckboxStyle()
|
|
}
|
|
|
|
// MARK: - Headings
|
|
|
|
/// Per-level heading metrics. Defaults follow the historical Nodes ratios,
|
|
/// which are loosely based on browser default heading sizes.
|
|
public struct HeadingStyle: Sendable {
|
|
/// Font-size multiplier per heading level (1...6).
|
|
public var fontMultipliers: [CGFloat]
|
|
/// Top spacing in `em` units per heading level (1...6).
|
|
public var topSpacingEm: [CGFloat]
|
|
|
|
public init(
|
|
fontMultipliers: [CGFloat] = [2.0, 1.5, 1.17, 1.0, 0.83, 0.67],
|
|
topSpacingEm: [CGFloat] = [0.35, 0.30, 0.25, 0.20, 0.15, 0.10]
|
|
) {
|
|
self.fontMultipliers = fontMultipliers
|
|
self.topSpacingEm = topSpacingEm
|
|
}
|
|
|
|
public func fontMultiplier(for level: Int) -> CGFloat {
|
|
let index = max(1, min(level, fontMultipliers.count)) - 1
|
|
return fontMultipliers[index]
|
|
}
|
|
|
|
public func topSpacingEm(for level: Int) -> CGFloat {
|
|
let index = max(1, min(level, topSpacingEm.count)) - 1
|
|
return topSpacingEm[index]
|
|
}
|
|
|
|
public static let `default` = HeadingStyle()
|
|
}
|
|
|
|
// MARK: - Image embeds (![[...]])
|
|
|
|
/// Sizing and spacing rules for `![[Name]]` image embeds.
|
|
public struct ImageEmbedStyle: Sendable {
|
|
/// Minimum allowed display width (points) for an embedded image.
|
|
public var minimumWidth: CGFloat
|
|
/// Fallback maximum width if no usable text container width is available.
|
|
public var fallbackMaxWidth: CGFloat
|
|
/// Sanity bound — container widths above this are treated as invalid.
|
|
public var unreasonableMaxWidth: CGFloat
|
|
/// Vertical paragraph spacing above/below the image paragraph.
|
|
public var paragraphSpacing: CGFloat
|
|
/// Gap between the source line and the rendered image (visibleSource mode).
|
|
public var imageGap: CGFloat
|
|
|
|
public init(
|
|
minimumWidth: CGFloat = 50,
|
|
fallbackMaxWidth: CGFloat = 650,
|
|
unreasonableMaxWidth: CGFloat = 1_000_000,
|
|
paragraphSpacing: CGFloat = 8,
|
|
imageGap: CGFloat = 8
|
|
) {
|
|
self.minimumWidth = minimumWidth
|
|
self.fallbackMaxWidth = fallbackMaxWidth
|
|
self.unreasonableMaxWidth = unreasonableMaxWidth
|
|
self.paragraphSpacing = paragraphSpacing
|
|
self.imageGap = imageGap
|
|
}
|
|
|
|
public static let `default` = ImageEmbedStyle()
|
|
}
|
|
|
|
// MARK: - LaTeX
|
|
|
|
/// Vertical spacing for block-LaTeX `$$...$$` paragraphs.
|
|
public struct BlockLatexStyle: Sendable {
|
|
/// Top spacing for $$...$$ block paragraphs.
|
|
public var paragraphSpacingBefore: CGFloat
|
|
/// Bottom spacing for $$...$$ block paragraphs.
|
|
public var paragraphSpacing: CGFloat
|
|
/// Extra bottom padding added to single-letter formulas to avoid clipping.
|
|
public var singleLetterPaddingBottom: CGFloat
|
|
|
|
public init(
|
|
paragraphSpacingBefore: CGFloat = 16,
|
|
paragraphSpacing: CGFloat = 20,
|
|
singleLetterPaddingBottom: CGFloat = 1.0
|
|
) {
|
|
self.paragraphSpacingBefore = paragraphSpacingBefore
|
|
self.paragraphSpacing = paragraphSpacing
|
|
self.singleLetterPaddingBottom = singleLetterPaddingBottom
|
|
}
|
|
|
|
public static let `default` = BlockLatexStyle()
|
|
}
|
|
|
|
/// Reserved for future inline-LaTeX (`$...$`) tuning. Currently has no
|
|
/// effect; inline LaTeX inherits font size from the surrounding context.
|
|
public struct InlineLatexStyle: Sendable {
|
|
/// Reserved for future inline-LaTeX tuning — currently the engine inherits
|
|
/// font size from the surrounding heading context.
|
|
public var placeholder: Void
|
|
|
|
public init() { self.placeholder = () }
|
|
|
|
public static let `default` = InlineLatexStyle()
|
|
}
|
|
|
|
// MARK: - Blockquote
|
|
|
|
/// Extra line height added to blockquote lines.
|
|
///
|
|
/// By default blockquote lines use the font's natural line height with no
|
|
/// extra spacing. Set `extraLineHeight` to add breathing room, matching
|
|
/// the pattern used by `ListStyle.extraLineHeight` and
|
|
/// `ParagraphStyle.lineHeightExtraSpacing`.
|
|
public struct BlockquoteStyle: Sendable {
|
|
/// Extra height (points) added to the default line height for blockquote lines.
|
|
public var extraLineHeight: CGFloat
|
|
|
|
public init(extraLineHeight: CGFloat = 0) {
|
|
self.extraLineHeight = extraLineHeight
|
|
}
|
|
|
|
public static let `default` = BlockquoteStyle()
|
|
}
|
|
|
|
// MARK: - Links
|
|
|
|
/// Foreground alpha values applied to link content in different states.
|
|
public struct LinkStyle: Sendable {
|
|
/// Foreground alpha for the visible label of an active markdown link.
|
|
public var activeLinkAlpha: CGFloat
|
|
/// Foreground alpha applied to "incomplete" link content (e.g. `[text]`
|
|
/// without a target).
|
|
public var incompleteLinkAlpha: CGFloat
|
|
|
|
public init(activeLinkAlpha: CGFloat = 0.55, incompleteLinkAlpha: CGFloat = 0.7) {
|
|
self.activeLinkAlpha = activeLinkAlpha
|
|
self.incompleteLinkAlpha = incompleteLinkAlpha
|
|
}
|
|
|
|
public static let `default` = LinkStyle()
|
|
}
|
|
|
|
// MARK: - Paragraphs
|
|
|
|
/// Default paragraph spacing and line height applied to body text.
|
|
public struct ParagraphStyle: Sendable {
|
|
/// Extra paragraph spacing as a fraction of the document's default line height.
|
|
public var spacingFactor: CGFloat
|
|
/// Extra height (points) added to the default paragraph line height.
|
|
public var lineHeightExtraSpacing: CGFloat
|
|
|
|
public init(spacingFactor: CGFloat = 0.3, lineHeightExtraSpacing: CGFloat = 2) {
|
|
self.spacingFactor = spacingFactor
|
|
self.lineHeightExtraSpacing = lineHeightExtraSpacing
|
|
}
|
|
|
|
public static let `default` = ParagraphStyle()
|
|
}
|
|
|
|
// MARK: - Bottom overscroll
|
|
|
|
/// Controls the empty space below the last line so that typing at the bottom
|
|
/// of a long document remains comfortable instead of pinning to the viewport
|
|
/// bottom edge.
|
|
public struct OverscrollPolicy: Sendable {
|
|
/// Desired overscroll as a fraction of the visible viewport height.
|
|
public var percent: CGFloat
|
|
/// Hard upper bound for the overscroll in points.
|
|
public var maxPoints: CGFloat
|
|
/// Hard lower bound for the overscroll in points.
|
|
public var minPoints: CGFloat
|
|
/// Fraction of the viewport above which overscroll starts ramping up.
|
|
public var activationStartFraction: CGFloat
|
|
/// Fraction of the viewport over which overscroll fully ramps in.
|
|
public var activationRangeFraction: CGFloat
|
|
|
|
public init(
|
|
percent: CGFloat = 0.5,
|
|
maxPoints: CGFloat = 450,
|
|
minPoints: CGFloat = 40,
|
|
activationStartFraction: CGFloat = 0.15,
|
|
activationRangeFraction: CGFloat = 0.85
|
|
) {
|
|
self.percent = percent
|
|
self.maxPoints = maxPoints
|
|
self.minPoints = minPoints
|
|
self.activationStartFraction = activationStartFraction
|
|
self.activationRangeFraction = activationRangeFraction
|
|
}
|
|
|
|
public static let `default` = OverscrollPolicy()
|
|
}
|
|
|
|
// MARK: - Drag selection
|
|
|
|
/// Tuning for the auto-scroll boost that engages while the user drags a
|
|
/// selection past the visible viewport edges.
|
|
public struct DragSelectionPolicy: Sendable {
|
|
/// Movement threshold (points) before the auto-scroll boost engages.
|
|
public var movementThreshold: CGFloat
|
|
/// Distance from the window edge that triggers the boost.
|
|
public var edgeTriggerDistance: CGFloat
|
|
/// Pixels per tick scrolled while the boost is active.
|
|
public var scrollStepPerTick: CGFloat
|
|
/// Boost timer frequency (ticks per second).
|
|
public var ticksPerSecond: Double
|
|
|
|
public init(
|
|
movementThreshold: CGFloat = 5.0,
|
|
edgeTriggerDistance: CGFloat = 5.0,
|
|
scrollStepPerTick: CGFloat = 12.0,
|
|
ticksPerSecond: Double = 60.0
|
|
) {
|
|
self.movementThreshold = movementThreshold
|
|
self.edgeTriggerDistance = edgeTriggerDistance
|
|
self.scrollStepPerTick = scrollStepPerTick
|
|
self.ticksPerSecond = ticksPerSecond
|
|
}
|
|
|
|
public static let `default` = DragSelectionPolicy()
|
|
}
|
|
|
|
// MARK: - Safe-area insets
|
|
|
|
/// Reserves space on the scroll view for system overlays (e.g. a translucent toolbar to scroll underneath). Maps to `NSScrollView.contentInsets`; scroll bar follows the inset.
|
|
public struct SafeAreaInsets: Sendable {
|
|
public var top: CGFloat
|
|
public var leading: CGFloat
|
|
public var trailing: CGFloat
|
|
public var bottom: CGFloat
|
|
|
|
public init(
|
|
top: CGFloat = 0,
|
|
leading: CGFloat = 0,
|
|
trailing: CGFloat = 0,
|
|
bottom: CGFloat = 0
|
|
) {
|
|
self.top = top
|
|
self.leading = leading
|
|
self.trailing = trailing
|
|
self.bottom = bottom
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|