add macOS PagedBookView, wire book mode in both reader views

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 07:46:50 +01:00
parent 46f7d255eb
commit 77057aec7c
4 changed files with 223 additions and 16 deletions

View File

@@ -72,6 +72,16 @@ struct ReaderView: View {
private func readingContent(attributedText: NSAttributedString) -> some View {
let highlightRange = viewModel.currentSentenceRange()
if readingMode == "book" {
PagedBookView(
attributedText: attributedText,
highlightRange: highlightRange,
onTapCharacter: { offset in
Task { try await viewModel.startPlayback(from: offset) }
},
scrollToOffset: scrollToChapterOffset
)
} else {
BookTextView(
attributedText: attributedText,
highlightRange: highlightRange,
@@ -81,6 +91,7 @@ struct ReaderView: View {
scrollToOffset: scrollToChapterOffset
)
}
}
private func loadBook() async {
readingMode = storedBook.readingMode ?? "scroll"

View File

@@ -72,6 +72,16 @@ struct MacReaderView: View {
private func readingContent(attributedText: NSAttributedString) -> some View {
let highlightRange = viewModel.currentSentenceRange()
if readingMode == "book" {
PagedBookView(
attributedText: attributedText,
highlightRange: highlightRange,
onClickCharacter: { offset in
Task { try await viewModel.startPlayback(from: offset) }
},
scrollToOffset: scrollToChapterOffset
)
} else {
BookTextView(
attributedText: attributedText,
highlightRange: highlightRange,
@@ -81,6 +91,7 @@ struct MacReaderView: View {
scrollToOffset: scrollToChapterOffset
)
}
}
private func loadBook() async {
readingMode = storedBook.readingMode ?? "scroll"

View File

@@ -0,0 +1,177 @@
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)
}
}
}
}

View File

@@ -9,8 +9,10 @@
/* Begin PBXBuildFile section */
1EAA6CE8D165697C44F758E1 /* Synthesizer in Frameworks */ = {isa = PBXBuildFile; productRef = 27EA57D961AE7F78BD042C7D /* Synthesizer */; };
296593F8EAFF71BDDDD258AD /* voices.npz in Resources */ = {isa = PBXBuildFile; fileRef = DD450CF6A4B5C744313FCCCC /* voices.npz */; };
3832E0D33A4C109BF2E00F25 /* PagedBookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8294FBF66215BF4082A2E1B /* PagedBookView.swift */; };
42DC7EA3FDECF1F8557ECE50 /* Storage in Frameworks */ = {isa = PBXBuildFile; productRef = CC9654FFF9E6F93F672291D1 /* Storage */; };
478DDD72F4BF97B2E5697C0F /* VorleserApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3129908D008B3F6FA0777D /* VorleserApp.swift */; };
4F14E8FFCAC55749BB0234D8 /* PagedBookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F2E07663FD305CD8BE14A3 /* PagedBookView.swift */; };
64BBA5CAF37B44980DB9A20B /* MacPlaybackControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032574858834489235F8E70B /* MacPlaybackControls.swift */; };
6727B5E58A877E698FEA21E3 /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2DCB6321555456B400716 /* MacLibraryView.swift */; };
85BC42AC0D0400C20407E332 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63908D55740A037BF58A5D5 /* LibraryView.swift */; };
@@ -46,8 +48,10 @@
D8BC5EDC47085426247216C2 /* Vorleser-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Vorleser-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
DD450CF6A4B5C744313FCCCC /* voices.npz */ = {isa = PBXFileReference; path = voices.npz; sourceTree = "<group>"; };
E1D2DCB6321555456B400716 /* MacLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryView.swift; sourceTree = "<group>"; };
E1F2E07663FD305CD8BE14A3 /* PagedBookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedBookView.swift; sourceTree = "<group>"; };
E67F95D90A3116255C29F5BF /* MacReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacReaderView.swift; sourceTree = "<group>"; };
F659E46345F099B2D20F426C /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = "<group>"; };
F8294FBF66215BF4082A2E1B /* PagedBookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedBookView.swift; sourceTree = "<group>"; };
FD5E22A3829AA6729CFA9FA8 /* BookTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookTextView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -111,6 +115,7 @@
E1D2DCB6321555456B400716 /* MacLibraryView.swift */,
032574858834489235F8E70B /* MacPlaybackControls.swift */,
E67F95D90A3116255C29F5BF /* MacReaderView.swift */,
E1F2E07663FD305CD8BE14A3 /* PagedBookView.swift */,
674640CCD25FD35F3A950480 /* VorleserMacApp.swift */,
);
path = "Vorleser-macOS";
@@ -140,6 +145,7 @@
children = (
10B1CF58D0A84F564B0C2B01 /* BookTextView.swift */,
A63908D55740A037BF58A5D5 /* LibraryView.swift */,
F8294FBF66215BF4082A2E1B /* PagedBookView.swift */,
5646AE29793AF3F9C5ED0C12 /* PlaybackControls.swift */,
F659E46345F099B2D20F426C /* ReaderView.swift */,
2F3129908D008B3F6FA0777D /* VorleserApp.swift */,
@@ -269,6 +275,7 @@
files = (
BC5528C6EC416119A2B866E4 /* BookTextView.swift in Sources */,
85BC42AC0D0400C20407E332 /* LibraryView.swift in Sources */,
3832E0D33A4C109BF2E00F25 /* PagedBookView.swift in Sources */,
A914F9C0B0E6FD8E13B8F99D /* PlaybackControls.swift in Sources */,
A4C2566EFB0CD3ACB176D9CC /* ReaderView.swift in Sources */,
478DDD72F4BF97B2E5697C0F /* VorleserApp.swift in Sources */,
@@ -283,6 +290,7 @@
6727B5E58A877E698FEA21E3 /* MacLibraryView.swift in Sources */,
64BBA5CAF37B44980DB9A20B /* MacPlaybackControls.swift in Sources */,
DF0BDDB16296D30F090B9CD1 /* MacReaderView.swift in Sources */,
4F14E8FFCAC55749BB0234D8 /* PagedBookView.swift in Sources */,
860AEFF3196B242AEA14453E /* VorleserMacApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;