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>
607 lines
22 KiB
Swift
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
|