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,14 +72,25 @@ struct ReaderView: View {
|
|||||||
private func readingContent(attributedText: NSAttributedString) -> some View {
|
private func readingContent(attributedText: NSAttributedString) -> some View {
|
||||||
let highlightRange = viewModel.currentSentenceRange()
|
let highlightRange = viewModel.currentSentenceRange()
|
||||||
|
|
||||||
BookTextView(
|
if readingMode == "book" {
|
||||||
attributedText: attributedText,
|
PagedBookView(
|
||||||
highlightRange: highlightRange,
|
attributedText: attributedText,
|
||||||
onTapCharacter: { offset in
|
highlightRange: highlightRange,
|
||||||
Task { try await viewModel.startPlayback(from: offset) }
|
onTapCharacter: { offset in
|
||||||
},
|
Task { try await viewModel.startPlayback(from: offset) }
|
||||||
scrollToOffset: scrollToChapterOffset
|
},
|
||||||
)
|
scrollToOffset: scrollToChapterOffset
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BookTextView(
|
||||||
|
attributedText: attributedText,
|
||||||
|
highlightRange: highlightRange,
|
||||||
|
onTapCharacter: { offset in
|
||||||
|
Task { try await viewModel.startPlayback(from: offset) }
|
||||||
|
},
|
||||||
|
scrollToOffset: scrollToChapterOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadBook() async {
|
private func loadBook() async {
|
||||||
|
|||||||
@@ -72,14 +72,25 @@ struct MacReaderView: View {
|
|||||||
private func readingContent(attributedText: NSAttributedString) -> some View {
|
private func readingContent(attributedText: NSAttributedString) -> some View {
|
||||||
let highlightRange = viewModel.currentSentenceRange()
|
let highlightRange = viewModel.currentSentenceRange()
|
||||||
|
|
||||||
BookTextView(
|
if readingMode == "book" {
|
||||||
attributedText: attributedText,
|
PagedBookView(
|
||||||
highlightRange: highlightRange,
|
attributedText: attributedText,
|
||||||
onClickCharacter: { offset in
|
highlightRange: highlightRange,
|
||||||
Task { try await viewModel.startPlayback(from: offset) }
|
onClickCharacter: { offset in
|
||||||
},
|
Task { try await viewModel.startPlayback(from: offset) }
|
||||||
scrollToOffset: scrollToChapterOffset
|
},
|
||||||
)
|
scrollToOffset: scrollToChapterOffset
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BookTextView(
|
||||||
|
attributedText: attributedText,
|
||||||
|
highlightRange: highlightRange,
|
||||||
|
onClickCharacter: { offset in
|
||||||
|
Task { try await viewModel.startPlayback(from: offset) }
|
||||||
|
},
|
||||||
|
scrollToOffset: scrollToChapterOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadBook() async {
|
private func loadBook() async {
|
||||||
|
|||||||
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 */
|
/* Begin PBXBuildFile section */
|
||||||
1EAA6CE8D165697C44F758E1 /* Synthesizer in Frameworks */ = {isa = PBXBuildFile; productRef = 27EA57D961AE7F78BD042C7D /* Synthesizer */; };
|
1EAA6CE8D165697C44F758E1 /* Synthesizer in Frameworks */ = {isa = PBXBuildFile; productRef = 27EA57D961AE7F78BD042C7D /* Synthesizer */; };
|
||||||
296593F8EAFF71BDDDD258AD /* voices.npz in Resources */ = {isa = PBXBuildFile; fileRef = DD450CF6A4B5C744313FCCCC /* voices.npz */; };
|
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 */; };
|
42DC7EA3FDECF1F8557ECE50 /* Storage in Frameworks */ = {isa = PBXBuildFile; productRef = CC9654FFF9E6F93F672291D1 /* Storage */; };
|
||||||
478DDD72F4BF97B2E5697C0F /* VorleserApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3129908D008B3F6FA0777D /* VorleserApp.swift */; };
|
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 */; };
|
64BBA5CAF37B44980DB9A20B /* MacPlaybackControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032574858834489235F8E70B /* MacPlaybackControls.swift */; };
|
||||||
6727B5E58A877E698FEA21E3 /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2DCB6321555456B400716 /* MacLibraryView.swift */; };
|
6727B5E58A877E698FEA21E3 /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2DCB6321555456B400716 /* MacLibraryView.swift */; };
|
||||||
85BC42AC0D0400C20407E332 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63908D55740A037BF58A5D5 /* LibraryView.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; };
|
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>"; };
|
DD450CF6A4B5C744313FCCCC /* voices.npz */ = {isa = PBXFileReference; path = voices.npz; sourceTree = "<group>"; };
|
||||||
E1D2DCB6321555456B400716 /* MacLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryView.swift; 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>"; };
|
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>"; };
|
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>"; };
|
FD5E22A3829AA6729CFA9FA8 /* BookTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookTextView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -111,6 +115,7 @@
|
|||||||
E1D2DCB6321555456B400716 /* MacLibraryView.swift */,
|
E1D2DCB6321555456B400716 /* MacLibraryView.swift */,
|
||||||
032574858834489235F8E70B /* MacPlaybackControls.swift */,
|
032574858834489235F8E70B /* MacPlaybackControls.swift */,
|
||||||
E67F95D90A3116255C29F5BF /* MacReaderView.swift */,
|
E67F95D90A3116255C29F5BF /* MacReaderView.swift */,
|
||||||
|
E1F2E07663FD305CD8BE14A3 /* PagedBookView.swift */,
|
||||||
674640CCD25FD35F3A950480 /* VorleserMacApp.swift */,
|
674640CCD25FD35F3A950480 /* VorleserMacApp.swift */,
|
||||||
);
|
);
|
||||||
path = "Vorleser-macOS";
|
path = "Vorleser-macOS";
|
||||||
@@ -140,6 +145,7 @@
|
|||||||
children = (
|
children = (
|
||||||
10B1CF58D0A84F564B0C2B01 /* BookTextView.swift */,
|
10B1CF58D0A84F564B0C2B01 /* BookTextView.swift */,
|
||||||
A63908D55740A037BF58A5D5 /* LibraryView.swift */,
|
A63908D55740A037BF58A5D5 /* LibraryView.swift */,
|
||||||
|
F8294FBF66215BF4082A2E1B /* PagedBookView.swift */,
|
||||||
5646AE29793AF3F9C5ED0C12 /* PlaybackControls.swift */,
|
5646AE29793AF3F9C5ED0C12 /* PlaybackControls.swift */,
|
||||||
F659E46345F099B2D20F426C /* ReaderView.swift */,
|
F659E46345F099B2D20F426C /* ReaderView.swift */,
|
||||||
2F3129908D008B3F6FA0777D /* VorleserApp.swift */,
|
2F3129908D008B3F6FA0777D /* VorleserApp.swift */,
|
||||||
@@ -269,6 +275,7 @@
|
|||||||
files = (
|
files = (
|
||||||
BC5528C6EC416119A2B866E4 /* BookTextView.swift in Sources */,
|
BC5528C6EC416119A2B866E4 /* BookTextView.swift in Sources */,
|
||||||
85BC42AC0D0400C20407E332 /* LibraryView.swift in Sources */,
|
85BC42AC0D0400C20407E332 /* LibraryView.swift in Sources */,
|
||||||
|
3832E0D33A4C109BF2E00F25 /* PagedBookView.swift in Sources */,
|
||||||
A914F9C0B0E6FD8E13B8F99D /* PlaybackControls.swift in Sources */,
|
A914F9C0B0E6FD8E13B8F99D /* PlaybackControls.swift in Sources */,
|
||||||
A4C2566EFB0CD3ACB176D9CC /* ReaderView.swift in Sources */,
|
A4C2566EFB0CD3ACB176D9CC /* ReaderView.swift in Sources */,
|
||||||
478DDD72F4BF97B2E5697C0F /* VorleserApp.swift in Sources */,
|
478DDD72F4BF97B2E5697C0F /* VorleserApp.swift in Sources */,
|
||||||
@@ -283,6 +290,7 @@
|
|||||||
6727B5E58A877E698FEA21E3 /* MacLibraryView.swift in Sources */,
|
6727B5E58A877E698FEA21E3 /* MacLibraryView.swift in Sources */,
|
||||||
64BBA5CAF37B44980DB9A20B /* MacPlaybackControls.swift in Sources */,
|
64BBA5CAF37B44980DB9A20B /* MacPlaybackControls.swift in Sources */,
|
||||||
DF0BDDB16296D30F090B9CD1 /* MacReaderView.swift in Sources */,
|
DF0BDDB16296D30F090B9CD1 /* MacReaderView.swift in Sources */,
|
||||||
|
4F14E8FFCAC55749BB0234D8 /* PagedBookView.swift in Sources */,
|
||||||
860AEFF3196B242AEA14453E /* VorleserMacApp.swift in Sources */,
|
860AEFF3196B242AEA14453E /* VorleserMacApp.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user