178 lines
5.5 KiB
Swift
178 lines
5.5 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import VorleserKit
|
|
|
|
/// A custom NSView subclass that accepts keyboard events and forwards them to the coordinator.
|
|
private class PagedContainerView: NSView {
|
|
weak var coordinator: PagedBookView.Coordinator?
|
|
|
|
override var acceptsFirstResponder: Bool { true }
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
switch event.keyCode {
|
|
case 123: // left arrow
|
|
coordinator?.navigatePage(delta: -1)
|
|
case 124: // right arrow
|
|
coordinator?.navigatePage(delta: 1)
|
|
default:
|
|
super.keyDown(with: event)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PagedBookView: NSViewRepresentable {
|
|
let attributedText: NSAttributedString
|
|
let highlightRange: Range<Int>?
|
|
let onClickCharacter: (CharacterOffset) -> Void
|
|
var scrollToOffset: CharacterOffset?
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let container = PagedContainerView()
|
|
container.coordinator = context.coordinator
|
|
|
|
let textView = NSTextView()
|
|
textView.isEditable = false
|
|
textView.isSelectable = false
|
|
textView.textContainerInset = NSSize(width: 16, height: 16)
|
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
|
container.addSubview(textView)
|
|
NSLayoutConstraint.activate([
|
|
textView.topAnchor.constraint(equalTo: container.topAnchor),
|
|
textView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
textView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
textView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
])
|
|
|
|
let click = NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleClick(_:)))
|
|
textView.addGestureRecognizer(click)
|
|
context.coordinator.textView = textView
|
|
|
|
DispatchQueue.main.async {
|
|
container.window?.makeFirstResponder(container)
|
|
}
|
|
|
|
return container
|
|
}
|
|
|
|
func updateNSView(_ container: NSView, context: Context) {
|
|
guard let textView = context.coordinator.textView else { return }
|
|
|
|
context.coordinator.parent = self
|
|
context.coordinator.recomputePages(for: container.bounds.size)
|
|
|
|
if let offset = scrollToOffset {
|
|
context.coordinator.currentPageIndex = context.coordinator.pageIndex(containing: offset)
|
|
} else if let range = highlightRange {
|
|
let pageIndex = context.coordinator.pageIndex(containing: range.lowerBound)
|
|
if pageIndex != context.coordinator.currentPageIndex {
|
|
context.coordinator.currentPageIndex = pageIndex
|
|
}
|
|
}
|
|
|
|
context.coordinator.displayCurrentPage()
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(parent: self)
|
|
}
|
|
|
|
class Coordinator: NSObject {
|
|
var parent: PagedBookView
|
|
weak var textView: NSTextView?
|
|
var currentPageIndex = 0
|
|
var pageBreaks: [Int] = [0]
|
|
|
|
init(parent: PagedBookView) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func recomputePages(for size: CGSize) {
|
|
guard parent.attributedText.length > 0 else {
|
|
pageBreaks = [0]
|
|
return
|
|
}
|
|
|
|
let insets = NSSize(width: 16, height: 16)
|
|
let pageWidth = size.width - insets.width * 2
|
|
let pageHeight = size.height - insets.height * 2
|
|
guard pageWidth > 0, pageHeight > 0 else {
|
|
pageBreaks = [0]
|
|
return
|
|
}
|
|
|
|
let textStorage = NSTextStorage(attributedString: parent.attributedText)
|
|
let layoutManager = NSLayoutManager()
|
|
textStorage.addLayoutManager(layoutManager)
|
|
|
|
var breaks: [Int] = [0]
|
|
var offset = 0
|
|
|
|
while offset < parent.attributedText.length {
|
|
let container = NSTextContainer(size: NSSize(width: pageWidth, height: pageHeight))
|
|
container.lineFragmentPadding = 0
|
|
layoutManager.addTextContainer(container)
|
|
|
|
let glyphRange = layoutManager.glyphRange(for: container)
|
|
let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
|
|
|
|
if charRange.length == 0 { break }
|
|
|
|
offset = charRange.location + charRange.length
|
|
if offset < parent.attributedText.length {
|
|
breaks.append(offset)
|
|
}
|
|
}
|
|
|
|
pageBreaks = breaks
|
|
}
|
|
|
|
func pageIndex(containing characterOffset: Int) -> Int {
|
|
for (i, breakOffset) in pageBreaks.enumerated().reversed() {
|
|
if characterOffset >= breakOffset {
|
|
return i
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func displayCurrentPage() {
|
|
guard let textView else { return }
|
|
let index = min(currentPageIndex, pageBreaks.count - 1)
|
|
let start = pageBreaks[index]
|
|
let end = index + 1 < pageBreaks.count ? pageBreaks[index + 1] : parent.attributedText.length
|
|
let range = NSRange(location: start, length: end - start)
|
|
|
|
let pageText = NSMutableAttributedString(attributedString: parent.attributedText.attributedSubstring(from: range))
|
|
|
|
if let highlightRange = parent.highlightRange {
|
|
let hlStart = max(highlightRange.lowerBound - start, 0)
|
|
let hlEnd = min(highlightRange.upperBound - start, pageText.length)
|
|
if hlStart < hlEnd {
|
|
let nsRange = NSRange(location: hlStart, length: hlEnd - hlStart)
|
|
pageText.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: nsRange)
|
|
}
|
|
}
|
|
|
|
textView.textStorage?.setAttributedString(pageText)
|
|
}
|
|
|
|
func navigatePage(delta: Int) {
|
|
let newIndex = currentPageIndex + delta
|
|
guard newIndex >= 0, newIndex < pageBreaks.count else { return }
|
|
currentPageIndex = newIndex
|
|
displayCurrentPage()
|
|
}
|
|
|
|
@objc func handleClick(_ gesture: NSClickGestureRecognizer) {
|
|
guard let textView else { return }
|
|
let point = gesture.location(in: textView)
|
|
let localIndex = textView.characterIndexForInsertion(at: point)
|
|
let pageStart = pageBreaks[min(currentPageIndex, pageBreaks.count - 1)]
|
|
let globalIndex = pageStart + localIndex
|
|
if globalIndex < parent.attributedText.length {
|
|
parent.onClickCharacter(globalIndex)
|
|
}
|
|
}
|
|
}
|
|
}
|