pageta-native/Sources/Features/ArticleBodyTextView.swift
Till JS 91bef19046 feat: In-Artikel-Suche im Reader (find-in-page)
Cmd+F bzw. Toolbar-Eintrag öffnet eine schwebende Such-Leiste mit
Treffer-Zähler und ▲/▼-Navigation — bündig zur Web-App.

Bewusst eigene Suche statt System-UIFindInteraction/NSTextFinder, die
eine selbst-scrollende TextView voraussetzen (hier per Architektur-
Invariante isScrollEnabled=false). Treffer als NSRange über denselben
cleanedBody-String; eigener Such-Background-Layer (gelb/aktiv-orange),
getrennt von den User-Highlights, verschwindet beim Schließen restlos.

Scroll-zu-Treffer über eine ArticleBodyController-Rect-Brücke (textview-
lokales Rect, iOS firstRect / macOS layoutManager) + im readerSpace
gemessene Body-Position + Scroll-Offset. Der Offset läuft über eine
plain class (ReaderScrollTracker), damit Scroll-Ticks den teuren
Reader-Body nicht invalidieren.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:32:00 +02:00

607 lines
22 KiB
Swift

import ManaTokens
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
#if canImport(AppKit)
import AppKit
#endif
/// Eine Reader-Text-Auswahl: der ausgewählte String plus das Rechteck
/// der Auswahl in globalen (Screen-)Koordinaten, an dem die Inline-
/// Palette verankert wird. `anchorRect` ist nur auf iOS aussagekräftig;
/// auf macOS schwebt die Palette oben-zentriert und ignoriert den Rect.
struct TextSelection: Equatable {
let text: String
let anchorRect: CGRect
}
/// Brücke, über die der Parent (`ArticleDetailView`) das **textview-lokale**
/// Rechteck eines Treffer-Bereichs abfragen kann, um zur Fundstelle zu
/// scrollen (In-Artikel-Suche). Wird vom Representable bei jedem Update neu
/// verdrahtet, sodass der Parent immer gegen die aktuelle TextView fragt.
///
/// Bewusst textview-LOKALE Koordinaten (Ursprung oben-links): auf iOS via
/// `firstRect(for:)`, auf macOS via `layoutManager.boundingRect(...)`. So
/// vermeiden wir die Screen-/Window-Koordinaten-Umrechnung (inkl.
/// macOS-Y-Flip), die für die schwebende Palette nicht lohnte. Der Parent
/// kombiniert das lokale Rect mit der gemessenen Body-Position + dem
/// Scroll-Offset zu einer absoluten Scroll-Zielposition.
final class ArticleBodyController {
/// Liefert das Rechteck eines `NSRange` in textview-lokalen Koordinaten,
/// oder `nil` wenn nicht ermittelbar (Range außerhalb / Layout fehlt).
var localRect: ((NSRange) -> CGRect?)?
}
/// Reader-Body als **eine einzige** `UITextView` / `NSTextView`.
///
/// **Architektur-Entscheidung 2026-05-21 (Option A):** Pro-Paragraph-
/// UITextViews waren ein Fehler — UITextView hat keine horizontale
/// Intrinsic-Size, Auto-Layout-Bridging zu SwiftUI wurde unzuverlässig
/// (Text lief über `frame(maxWidth: 720)`), Selection konnte nicht
/// über Paragraph-Grenzen hinweg, und Line-Spacing addierte sich mit
/// dem UITextView-Default. Industry-Pattern (Pocket, Instapaper,
/// Readwise, Apple Books) ist: eine TextView für den ganzen Body.
///
/// **Markieren 2026-05-25 (Books-Stil):** Statt „Markieren" im System-
/// Edit-Menü + Bottom-Sheet meldet die TextView jede Auswahl via
/// `onSelectionChange` (Text + Screen-Rect) nach oben. Der Parent zeigt
/// eine schwebende Farb-Palette direkt an der Auswahl. Das System-Edit-
/// Menü wird unterdrückt (sonst zwei konkurrierende UIs übereinander) —
/// „Kopieren" lebt stattdessen in der Palette.
///
/// **In-Artikel-Suche 2026-06-02:** Statt System-`UIFindInteraction`/
/// `NSTextFinder` (die eine selbst-scrollende TextView voraussetzen — hier
/// bewusst `isScrollEnabled = false`) eine eigene Suche: der Parent rechnet
/// Treffer als `NSRange` über denselben `cleanedBody`-String aus und reicht
/// sie als `searchMatches` + `activeMatchIndex` herein. Diese View legt
/// einen **eigenen** Background-Layer über die Treffer (getrennt von den
/// User-Highlights, damit der beim Schließen rückstandslos verschwindet)
/// und stellt über den `controller` das Treffer-Rechteck fürs Scrollen
/// bereit.
///
/// Highlight-Rendering: Wir suchen für jeden `Highlight.text` alle
/// Vorkommen im Body-String und setzen das `.backgroundColor`-Attribut.
/// Wenn der gleiche Text mehrfach im Artikel auftaucht, kriegen alle
/// Vorkommen den Background — akzeptiert, da das Wort-Level-Highlight
/// auf Text-Identität basiert (keine echten Offsets gespeichert).
struct ArticleBodyTextView: View {
let text: String
let highlights: [Highlight]
/// Aktuelle Auswahl nach oben (oder `nil` wenn nichts ausgewählt).
let onSelectionChange: (TextSelection?) -> Void
/// Spiegelt, ob der Parent die Auswahl noch für aktiv hält. Schaltet
/// er auf `false` (z.B. nach dem Markieren), löscht die View die noch
/// bestehende UIKit-Auswahl, damit die blauen Griffe verschwinden.
let selectionActive: Bool
/// Treffer der In-Artikel-Suche (NSRanges über `cleanedBody(text)`).
var searchMatches: [NSRange] = []
/// Index des aktiven Treffers (kräftiger eingefärbt), oder `nil`.
var activeMatchIndex: Int?
/// Brücke für die Treffer-Rect-Abfrage; default leer (kein Scrollen).
var controller: ArticleBodyController = .init()
var body: some View {
_ArticleBodyRepresentable(
text: Self.cleanedBody(text),
highlights: highlights,
onSelectionChange: onSelectionChange,
selectionActive: selectionActive,
searchMatches: searchMatches,
activeMatchIndex: activeMatchIndex,
controller: controller
)
}
/// Trimt + filtert leere Paragraphen, joined wieder mit `\n\n` damit
/// NSAttributedString die Paragraph-Spacing-Logik korrekt anwendet.
///
/// **Internal (nicht private):** Der Parent berechnet Such-Treffer gegen
/// **genau diesen** String, damit die `NSRange`-Offsets zum gerenderten
/// AttributedString passen. Single Source of Truth für den Body-String.
static func cleanedBody(_ raw: String) -> String {
raw.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
}
}
// MARK: - Shared
/// Such-Treffer-Hintergründe, getrennt von den User-Highlights gehalten,
/// damit sie beim Schließen der Suche rückstandslos verschwinden. Aktiver
/// Treffer kräftiger als die übrigen.
private enum SearchHighlightStyle {
#if canImport(UIKit)
static func inactive() -> UIColor {
UIColor.systemYellow.withAlphaComponent(0.55)
}
static func active() -> UIColor {
UIColor.systemOrange.withAlphaComponent(0.85)
}
#elseif canImport(AppKit)
static func inactive() -> NSColor {
NSColor.systemYellow.withAlphaComponent(0.55)
}
static func active() -> NSColor {
NSColor.systemOrange.withAlphaComponent(0.85)
}
#endif
}
/// Günstige Signatur über alles, was das Rendering beeinflusst: der Body,
/// die Highlight-Needles + Farben und der Such-Layer (Treffer + aktiver
/// Index). Ohne die Such-Anteile würde eine Treffer-Navigation den aktiven
/// Treffer nicht neu einfärben.
private func bodyContentSignature(
text: String,
highlights: [Highlight],
searchMatches: [NSRange],
activeMatchIndex: Int?
) -> String {
let highlightPart = highlights
.map { "\($0.id)|\($0.color.rawValue)|\($0.text)" }
.joined(separator: "¦")
let searchPart = searchMatches
.map { "\($0.location):\($0.length)" }
.joined(separator: ",")
return "\(text.count):\(text.hashValue):\(highlightPart):\(searchPart):\(activeMatchIndex ?? -1)"
}
// MARK: - iOS
#if canImport(UIKit)
private struct _ArticleBodyRepresentable: UIViewRepresentable {
let text: String
let highlights: [Highlight]
let onSelectionChange: (TextSelection?) -> Void
let selectionActive: Bool
let searchMatches: [NSRange]
let activeMatchIndex: Int?
let controller: ArticleBodyController
func makeCoordinator() -> Coordinator {
Coordinator(onSelectionChange: onSelectionChange)
}
func makeUIView(context: Context) -> _ArticleBodyUITextView {
let tv = _ArticleBodyUITextView()
tv.isEditable = false
tv.isSelectable = true
tv.isScrollEnabled = false
tv.backgroundColor = .clear
tv.textContainerInset = .zero
tv.textContainer.lineFragmentPadding = 0
tv.adjustsFontForContentSizeCategory = true
tv.delegate = context.coordinator
// Erlaubt SwiftUI's Parent die Breite zu diktieren; Höhe
// kommt aus sizeThatFits.
tv.setContentHuggingPriority(.defaultLow, for: .horizontal)
tv.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
tv.setContentHuggingPriority(.required, for: .vertical)
tv.setContentCompressionResistancePriority(.required, for: .vertical)
return tv
}
func updateUIView(_ tv: _ArticleBodyUITextView, context: Context) {
let coordinator = context.coordinator
coordinator.onSelectionChange = onSelectionChange
// Brücke fürs Scroll-zu-Treffer: textview-lokales Rect eines NSRange.
controller.localRect = { [weak tv] nsRange in
guard let tv,
let start = tv.position(from: tv.beginningOfDocument, offset: nsRange.location),
let end = tv.position(from: start, offset: nsRange.length),
let range = tv.textRange(from: start, to: end)
else { return nil }
let rect = tv.firstRect(for: range)
guard !rect.isNull, rect.minY.isFinite, rect.height.isFinite else { return nil }
return rect
}
// `attributedText` nur bei echter Inhaltsänderung neu setzen.
// Sonst würde jeder selection-getriebene Re-Render (selectionActive
// ist ein Input!) die UIKit-Auswahl abräumen → die Palette würde
// aufblitzen und sofort verschwinden. Die Such-State ist Teil der
// Signatur, damit Treffer-Navigation den aktiven Treffer neu färbt.
let signature = bodyContentSignature(
text: text,
highlights: highlights,
searchMatches: searchMatches,
activeMatchIndex: activeMatchIndex
)
if coordinator.contentSignature != signature {
coordinator.contentSignature = signature
coordinator.isProgrammaticUpdate = true
tv.attributedText = Self.makeAttributed(
text: text,
highlights: highlights,
searchMatches: searchMatches,
activeMatchIndex: activeMatchIndex
)
coordinator.isProgrammaticUpdate = false
tv.invalidateIntrinsicContentSize()
}
// Parent hat die Auswahl abgeräumt (z.B. nach dem Markieren) — die
// noch sichtbare UIKit-Auswahl mitnehmen, ohne dass das Delegate
// das als „neue Auswahl = nil" zurückmeldet.
if !selectionActive, tv.selectedRange.length > 0 {
coordinator.isProgrammaticUpdate = true
tv.selectedTextRange = nil
coordinator.isProgrammaticUpdate = false
}
}
/// Kritisch: SwiftUI fragt nach der vorgeschlagenen Breite, wir
/// liefern Höhe = boundingRect-Höhe bei dieser Breite zurück.
/// Ohne diesen Override versucht UITextView seine natürliche
/// Breite zu erzwingen → läuft über `maxWidth: 720`.
@available(iOS 16.0, *)
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: _ArticleBodyUITextView,
context _: Context
) -> CGSize? {
let width = proposal.width ?? uiView.bounds.width
guard width > 0 else { return nil }
let fitting = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return CGSize(width: width, height: ceil(fitting.height))
}
static func makeAttributed(
text: String,
highlights: [Highlight],
searchMatches: [NSRange],
activeMatchIndex: Int?
) -> NSAttributedString {
let serifFont: UIFont = {
// Marken-Serife des Vereins (Source Serif 4, identisch zum Web-
// Lese-Erlebnis), via ManaTokens gebündelt + registriert.
ManaFonts.register()
let bodyFont = UIFont.preferredFont(forTextStyle: .body)
let brandDesc = UIFontDescriptor(fontAttributes: [.family: ManaFonts.serif])
let brand = UIFont(descriptor: brandDesc, size: bodyFont.pointSize)
if brand.familyName == ManaFonts.serif {
return UIFontMetrics(forTextStyle: .body).scaledFont(for: brand)
}
// Fallback: System-Serife (New York), falls die Marken-Schrift
// wider Erwarten nicht registriert ist.
if let desc = bodyFont.fontDescriptor.withDesign(.serif) {
return UIFont(descriptor: desc, size: 0)
}
return bodyFont
}()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 5
paragraphStyle.paragraphSpacing = 10
let attributed = NSMutableAttributedString(string: text, attributes: [
.font: serifFont,
// Direkt UIColor — die Dynamic-Provider-Closure von
// PagetaTheme.foregroundUIColor wird vom UITextView bei
// traitCollectionDidChange neu ausgewertet (light/dark).
.foregroundColor: PagetaTheme.foregroundUIColor,
.paragraphStyle: paragraphStyle
])
applyHighlightBackgrounds(to: attributed, text: text, highlights: highlights)
applySearchBackgrounds(
to: attributed,
searchMatches: searchMatches,
activeMatchIndex: activeMatchIndex
)
return attributed
}
private static func applyHighlightBackgrounds(
to attributed: NSMutableAttributedString,
text: String,
highlights: [Highlight]
) {
for highlight in highlights {
let needle = highlight.text
guard !needle.isEmpty else { continue }
var searchRange = text.startIndex ..< text.endIndex
while let found = text.range(of: needle, range: searchRange) {
let nsRange = NSRange(found, in: text)
// Highlight-Farben sind absichtlich fix (Brand-
// Konsistenz), nur Alpha-Anteil hochgezogen damit
// die Markierung im Dark Mode genauso präsent ist.
let bg = UIColor(highlight.color.swiftUIColor).withAlphaComponent(0.45)
attributed.addAttribute(.backgroundColor, value: bg, range: nsRange)
searchRange = found.upperBound ..< text.endIndex
}
}
}
/// Such-Treffer-Layer über die User-Highlights. Letzter `addAttribute`
/// gewinnt — Treffer übermalen also eine etwaige Markierung an der
/// Stelle, was beim aktiven Springen gewollt ist.
private static func applySearchBackgrounds(
to attributed: NSMutableAttributedString,
searchMatches: [NSRange],
activeMatchIndex: Int?
) {
let full = NSRange(location: 0, length: attributed.length)
for (index, range) in searchMatches.enumerated() {
guard range.location != NSNotFound,
NSIntersectionRange(range, full).length == range.length
else { continue }
let color = index == activeMatchIndex
? SearchHighlightStyle.active()
: SearchHighlightStyle.inactive()
attributed.addAttribute(.backgroundColor, value: color, range: range)
}
}
/// Brücke UIKit-Selection → SwiftUI. Meldet bei jeder Selektions-
/// änderung den ausgewählten Text plus dessen Bildschirm-Rechteck
/// hoch (oder `nil` bei leerer Auswahl).
final class Coordinator: NSObject, UITextViewDelegate {
var onSelectionChange: (TextSelection?) -> Void
/// Inhalts-Signatur des zuletzt gesetzten Textes.
var contentSignature: String?
/// True während programmatischer Text-/Auswahl-Änderung — dann
/// ignorieren wir das Delegate-Callback (Re-Entrancy/State-Mutation).
var isProgrammaticUpdate = false
init(onSelectionChange: @escaping (TextSelection?) -> Void) {
self.onSelectionChange = onSelectionChange
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard !isProgrammaticUpdate else { return }
guard
let range = textView.selectedTextRange,
!range.isEmpty,
textView.selectedRange.length > 0
else {
onSelectionChange(nil)
return
}
let ns = textView.text as NSString
let selectedText = ns.substring(with: textView.selectedRange)
// Bounding-Rect der Auswahl (über mehrere Zeilen vereinen),
// dann in Fenster-/Screen-Koordinaten für `.global` in SwiftUI.
var union = CGRect.null
for selectionRect in textView.selectionRects(for: range) {
let r = selectionRect.rect
guard r.width > 0, r.height > 0 else { continue }
union = union.union(r)
}
if union.isNull {
union = textView.firstRect(for: range)
}
guard union.isNull == false, union.width.isFinite, union.height.isFinite else {
onSelectionChange(nil)
return
}
let global = textView.convert(union, to: nil)
onSelectionChange(TextSelection(text: selectedText, anchorRect: global))
}
}
}
/// UITextView-Subclass, die das System-Edit-Menü unterdrückt — die
/// Markier-Affordance läuft komplett über die schwebende SwiftUI-
/// Palette (siehe `ArticleDetailView`). Ein leeres `UIMenu` lässt
/// `UIEditMenuInteraction` nichts anzeigen.
final class _ArticleBodyUITextView: UITextView {
override func editMenu(for _: UITextRange, suggestedActions _: [UIMenuElement]) -> UIMenu? {
UIMenu(children: [])
}
}
#endif
// MARK: - macOS
#if canImport(AppKit)
private struct _ArticleBodyRepresentable: NSViewRepresentable {
let text: String
let highlights: [Highlight]
let onSelectionChange: (TextSelection?) -> Void
let selectionActive: Bool
let searchMatches: [NSRange]
let activeMatchIndex: Int?
let controller: ArticleBodyController
func makeCoordinator() -> Coordinator {
Coordinator(onSelectionChange: onSelectionChange)
}
func makeNSView(context: Context) -> _ArticleBodyNSTextView {
let tv = _ArticleBodyNSTextView(frame: .zero)
tv.isEditable = false
tv.isSelectable = true
tv.drawsBackground = false
tv.textContainerInset = .zero
tv.textContainer?.lineFragmentPadding = 0
tv.textContainer?.widthTracksTextView = true
tv.isVerticallyResizable = true
tv.isHorizontallyResizable = false
tv.autoresizingMask = [.width]
tv.delegate = context.coordinator
return tv
}
func updateNSView(_ tv: _ArticleBodyNSTextView, context: Context) {
let coordinator = context.coordinator
coordinator.onSelectionChange = onSelectionChange
// Brücke fürs Scroll-zu-Treffer: textview-lokales Rect eines NSRange.
// NSTextView ist flipped (Ursprung oben-links), Container-Origin
// aufaddieren für die echte Position im View.
controller.localRect = { [weak tv] nsRange in
guard let tv,
let lm = tv.layoutManager,
let tc = tv.textContainer
else { return nil }
let glyphRange = lm.glyphRange(forCharacterRange: nsRange, actualCharacterRange: nil)
var rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
let origin = tv.textContainerOrigin
rect.origin.x += origin.x
rect.origin.y += origin.y
guard rect.minY.isFinite, rect.height.isFinite else { return nil }
return rect
}
// Wie auf iOS: Text nur bei echter Inhalts-/Such-Änderung neu setzen,
// sonst räumt jeder selection-getriebene Re-Render die Auswahl ab.
let signature = bodyContentSignature(
text: text,
highlights: highlights,
searchMatches: searchMatches,
activeMatchIndex: activeMatchIndex
)
if coordinator.contentSignature != signature {
coordinator.contentSignature = signature
coordinator.isProgrammaticUpdate = true
tv.textStorage?.setAttributedString(Self.makeAttributed(
text: text,
highlights: highlights,
searchMatches: searchMatches,
activeMatchIndex: activeMatchIndex
))
coordinator.isProgrammaticUpdate = false
}
if !selectionActive, tv.selectedRange().length > 0 {
coordinator.isProgrammaticUpdate = true
tv.setSelectedRange(NSRange(location: 0, length: 0))
coordinator.isProgrammaticUpdate = false
}
}
@available(macOS 13.0, *)
func sizeThatFits(
_ proposal: ProposedViewSize,
nsView: _ArticleBodyNSTextView,
context _: Context
) -> CGSize? {
let width = proposal.width ?? nsView.bounds.width
guard width > 0 else { return nil }
// Width-Tracking auf den ContentSize anwenden und Layout
// erzwingen, damit der layoutManager die richtige Höhe liefert.
guard let textContainer = nsView.textContainer else { return nil }
textContainer.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude)
nsView.layoutManager?.ensureLayout(for: textContainer)
let used = nsView.layoutManager?.usedRect(for: textContainer).size ?? .zero
return NSSize(width: width, height: ceil(used.height))
}
static func makeAttributed(
text: String,
highlights: [Highlight],
searchMatches: [NSRange],
activeMatchIndex: Int?
) -> NSAttributedString {
ManaFonts.register()
let bodyFont = NSFont.preferredFont(forTextStyle: .body)
let brandDesc = NSFontDescriptor(fontAttributes: [.family: ManaFonts.serif])
let serifFont: NSFont
if let brand = NSFont(descriptor: brandDesc, size: bodyFont.pointSize),
brand.familyName == ManaFonts.serif
{
// Marken-Serife (Source Serif 4), identisch zum Web-Lese-Erlebnis.
// AppKit kennt kein `NSFontMetrics`/Dynamic Type — `brand` ist
// bereits auf `bodyFont.pointSize` dimensioniert.
serifFont = brand
} else {
// Fallback: System-Serife.
let desc = bodyFont.fontDescriptor.withDesign(.serif) ?? bodyFont.fontDescriptor
serifFont = NSFont(descriptor: desc, size: 0) ?? bodyFont
}
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 5
paragraphStyle.paragraphSpacing = 10
let attributed = NSMutableAttributedString(string: text, attributes: [
.font: serifFont,
.foregroundColor: PagetaTheme.foregroundNSColor,
.paragraphStyle: paragraphStyle
])
applyHighlightBackgrounds(to: attributed, text: text, highlights: highlights)
applySearchBackgrounds(
to: attributed,
searchMatches: searchMatches,
activeMatchIndex: activeMatchIndex
)
return attributed
}
private static func applyHighlightBackgrounds(
to attributed: NSMutableAttributedString,
text: String,
highlights: [Highlight]
) {
for highlight in highlights {
let needle = highlight.text
guard !needle.isEmpty else { continue }
var searchRange = text.startIndex ..< text.endIndex
while let found = text.range(of: needle, range: searchRange) {
let nsRange = NSRange(found, in: text)
let bg = NSColor(highlight.color.swiftUIColor).withAlphaComponent(0.45)
attributed.addAttribute(.backgroundColor, value: bg, range: nsRange)
searchRange = found.upperBound ..< text.endIndex
}
}
}
/// Such-Treffer-Layer über die User-Highlights (siehe iOS-Pendant).
private static func applySearchBackgrounds(
to attributed: NSMutableAttributedString,
searchMatches: [NSRange],
activeMatchIndex: Int?
) {
let full = NSRange(location: 0, length: attributed.length)
for (index, range) in searchMatches.enumerated() {
guard range.location != NSNotFound,
NSIntersectionRange(range, full).length == range.length
else { continue }
let color = index == activeMatchIndex
? SearchHighlightStyle.active()
: SearchHighlightStyle.inactive()
attributed.addAttribute(.backgroundColor, value: color, range: range)
}
}
/// macOS-Brücke. Der Rect bleibt `.zero` — die Palette schwebt auf
/// macOS oben-zentriert (Maus-Selektion + präzise NSView→SwiftUI-
/// Koordinaten-Umrechnung lohnt den Aufwand hier nicht).
final class Coordinator: NSObject, NSTextViewDelegate {
var onSelectionChange: (TextSelection?) -> Void
/// Inhalts-Signatur des zuletzt gesetzten Textes.
var contentSignature: String?
/// True während programmatischer Text-/Auswahl-Änderung — dann
/// ignorieren wir das Delegate-Callback (Re-Entrancy/State-Mutation).
var isProgrammaticUpdate = false
init(onSelectionChange: @escaping (TextSelection?) -> Void) {
self.onSelectionChange = onSelectionChange
}
func textViewDidChangeSelection(_ notification: Notification) {
guard !isProgrammaticUpdate else { return }
guard let tv = notification.object as? NSTextView else { return }
let range = tv.selectedRange()
guard range.length > 0 else {
onSelectionChange(nil)
return
}
let selectedText = (tv.string as NSString).substring(with: range)
onSelectionChange(TextSelection(text: selectedText, anchorRect: .zero))
}
}
}
final class _ArticleBodyNSTextView: NSTextView {}
#endif