From 232492f161508029ba1d23bb9b4ecb8ce9d03984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 05:51:29 +0100 Subject: [PATCH] add macOS BookTextView: NSViewRepresentable with NSAttributedString, highlight, click-to-seek, scroll-to-offset Co-Authored-By: Claude Sonnet 4.6 --- Vorleser-macOS/BookTextView.swift | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 Vorleser-macOS/BookTextView.swift diff --git a/Vorleser-macOS/BookTextView.swift b/Vorleser-macOS/BookTextView.swift new file mode 100644 index 0000000..faf9c98 --- /dev/null +++ b/Vorleser-macOS/BookTextView.swift @@ -0,0 +1,75 @@ +import SwiftUI +import AppKit +import VorleserKit + +struct BookTextView: NSViewRepresentable { + let attributedText: NSAttributedString + let highlightRange: Range? + let onClickCharacter: (CharacterOffset) -> Void + var scrollToOffset: CharacterOffset? + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + textView.isEditable = false + textView.isSelectable = false + textView.textContainerInset = NSSize(width: 16, height: 16) + + let click = NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleClick(_:))) + textView.addGestureRecognizer(click) + context.coordinator.textView = textView + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + + let highlighted = NSMutableAttributedString(attributedString: attributedText) + + if let range = highlightRange, + range.lowerBound >= 0, + range.upperBound <= attributedText.length { + let nsRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) + highlighted.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: nsRange) + } + + textView.textStorage?.setAttributedString(highlighted) + + if let range = highlightRange, + range.lowerBound >= 0, + range.upperBound <= attributedText.length { + let nsRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) + textView.scrollRangeToVisible(nsRange) + } + + if let offset = scrollToOffset, + offset >= 0, + offset < attributedText.length { + let nsRange = NSRange(location: offset, length: 1) + textView.scrollRangeToVisible(nsRange) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(onClickCharacter: onClickCharacter) + } + + class Coordinator: NSObject { + weak var textView: NSTextView? + let onClickCharacter: (CharacterOffset) -> Void + + init(onClickCharacter: @escaping (CharacterOffset) -> Void) { + self.onClickCharacter = onClickCharacter + } + + @objc func handleClick(_ gesture: NSClickGestureRecognizer) { + guard let textView else { return } + let point = gesture.location(in: textView) + let characterIndex = textView.characterIndexForInsertion(at: point) + if characterIndex < textView.string.count { + onClickCharacter(characterIndex) + } + } + } +}