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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
177
Vorleser-macOS/PagedBookView.swift
Normal file
177
Vorleser-macOS/PagedBookView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user