Convert from git submodule to plain vendored copy. No push access to upstream nodes-app/swift-markdown-engine meant our local fix commit (05c1720) was stranded and unreachable from any remote on fresh clones/CI. Vendoring as plain files folds it into normal repo history instead.
106 lines
4.8 KiB
Swift
106 lines
4.8 KiB
Swift
//
|
|
// NativeTextView.swift
|
|
// MarkdownEngine
|
|
//
|
|
// Created by Luca Chen on 18.02.26.
|
|
//
|
|
// AppKit `NSTextView` subclass used by the markdown editor. Stored state
|
|
// lives here; behavior is split across `NativeTextView+<Feature>.swift`
|
|
// files in this folder (frame & overscroll, caret workarounds, click remap,
|
|
// paste handling, drag-select boost, task checkbox, spelling policy).
|
|
//
|
|
// Bottom-overscroll math lives in `BottomOverscrollPolicy.swift`.
|
|
// Pasteboard image inspection lives in `PasteboardImageReader.swift`.
|
|
//
|
|
|
|
import AppKit
|
|
import UniformTypeIdentifiers
|
|
|
|
final class NativeTextView: NSTextView {
|
|
// MARK: Frame & overscroll state
|
|
var baseContentHeight: CGFloat = 0
|
|
var activeBottomOverscroll: CGFloat = 0
|
|
var isApplyingManagedFrameSize = false
|
|
/// Set on switch/resize to force full-layout height measurement until the cascade settles.
|
|
var pendingFullLayoutMeasure = false
|
|
/// Coalesces wide-table overlay updates to once per runloop (resize fires many per frame).
|
|
var pendingWideTableOverlayUpdate = false
|
|
var suppressAutoRevealOnce: Bool = false
|
|
// Set by clickedOnLink during a mouseDown: did the delegate fire (so
|
|
// mouseDown can re-dispatch a click AppKit dropped), and did it navigate
|
|
// (so the pre-click caret is restored — a link click isn't caret placement).
|
|
var linkClickDidFire = false
|
|
var linkClickDidNavigate = false
|
|
|
|
// MARK: Configuration
|
|
var configuration: MarkdownEditorConfiguration = .default {
|
|
didSet {
|
|
overscrollPercent = configuration.overscroll.percent
|
|
maxOverscrollPoints = configuration.overscroll.maxPoints
|
|
minOverscrollPoints = configuration.overscroll.minPoints
|
|
}
|
|
}
|
|
var overscrollPercent: CGFloat = MarkdownEditorConfiguration.default.overscroll.percent
|
|
var maxOverscrollPoints: CGFloat = MarkdownEditorConfiguration.default.overscroll.maxPoints
|
|
var minOverscrollPoints: CGFloat = MarkdownEditorConfiguration.default.overscroll.minPoints
|
|
|
|
// MARK: Editor wiring
|
|
var onPasteImage: ((NSPasteboard) -> String?)?
|
|
weak var layoutBridge: LayoutBridge?
|
|
var baseFont: NSFont = NSFont.systemFont(ofSize: NSFont.systemFontSize)
|
|
|
|
// MARK: Caret-workaround state
|
|
var caretIndicatorObservation: NSKeyValueObservation?
|
|
weak var observedCaretIndicator: NSView?
|
|
var isApplyingCaretShift: Bool = false
|
|
|
|
// MARK: Drag-select state
|
|
var dragStartMouseScreenLoc: NSPoint?
|
|
|
|
// MARK: Placeholder state
|
|
/// Click-through ghost-text label shown while the document is empty;
|
|
/// managed by `NativeTextView+Placeholder.swift`.
|
|
weak var placeholderView: PlaceholderLabelView?
|
|
|
|
// MARK: Cursor exclusion
|
|
/// Embedder-supplied predicate that suppresses the I-beam cursor in edit mode.
|
|
/// Called on every mouse-move with the event location in window coordinates.
|
|
/// Return `true` to show the arrow cursor instead of the edit-mode I-beam.
|
|
var isCursorExcluded: ((CGPoint) -> Bool)?
|
|
|
|
// MARK: Wide-table overlay state
|
|
/// Live NSScrollView per wide table; keyed by source-ID hash.
|
|
var wideTableOverlays: [Int: WideTableOverlay] = [:]
|
|
/// Persisted horizontal scroll offset per wide table; survives restyles.
|
|
var tableHorizontalScrollOffsets: [Int: CGFloat] = [:]
|
|
|
|
override func viewDidChangeEffectiveAppearance() {
|
|
super.viewDidChangeEffectiveAppearance()
|
|
// Forward appearance changes to the embedder's highlighter via its registered notification.
|
|
if let name = configuration.services.syntaxHighlighter.appearanceDidChangeNotification {
|
|
NotificationCenter.default.post(name: name, object: self)
|
|
}
|
|
}
|
|
|
|
// setMarkedText skips textDidChange, so restyle the marked paragraph to apply markdown attrs.
|
|
override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
|
super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange)
|
|
guard hasMarkedText(),
|
|
let coord = delegate as? NativeTextViewCoordinator else { return }
|
|
let marked = markedRange()
|
|
guard marked.location != NSNotFound, marked.length > 0 else { return }
|
|
// The composition mutated the storage without textDidChange, and
|
|
// shouldChangeTextIn's own parse re-cached the PRE-edit string at the
|
|
// current generation — bump so the restyle below reparses instead of
|
|
// serving that stale document (same-length composition updates).
|
|
coord.parseGeneration &+= 1
|
|
// Census bookkeeping never saw this mutation → next census full-scans.
|
|
coord.backtickCensusNeedsRescan = true
|
|
let nsText = self.string as NSString
|
|
let paragraph = nsText.paragraphRange(for: marked)
|
|
coord.restyleParagraphs([paragraph], in: self)
|
|
}
|
|
|
|
deinit { caretIndicatorObservation?.invalidate() }
|
|
}
|