diff --git a/Vorleser-iOS/ReaderView.swift b/Vorleser-iOS/ReaderView.swift index e069c9d..49f4562 100644 --- a/Vorleser-iOS/ReaderView.swift +++ b/Vorleser-iOS/ReaderView.swift @@ -72,14 +72,25 @@ struct ReaderView: View { private func readingContent(attributedText: NSAttributedString) -> some View { let highlightRange = viewModel.currentSentenceRange() - BookTextView( - attributedText: attributedText, - highlightRange: highlightRange, - onTapCharacter: { offset in - Task { try await viewModel.startPlayback(from: offset) } - }, - scrollToOffset: scrollToChapterOffset - ) + 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, + onTapCharacter: { offset in + Task { try await viewModel.startPlayback(from: offset) } + }, + scrollToOffset: scrollToChapterOffset + ) + } } private func loadBook() async { diff --git a/Vorleser-macOS/MacReaderView.swift b/Vorleser-macOS/MacReaderView.swift index c9f0f06..64fcbf6 100644 --- a/Vorleser-macOS/MacReaderView.swift +++ b/Vorleser-macOS/MacReaderView.swift @@ -72,14 +72,25 @@ struct MacReaderView: View { private func readingContent(attributedText: NSAttributedString) -> some View { let highlightRange = viewModel.currentSentenceRange() - BookTextView( - attributedText: attributedText, - highlightRange: highlightRange, - onClickCharacter: { offset in - Task { try await viewModel.startPlayback(from: offset) } - }, - scrollToOffset: scrollToChapterOffset - ) + 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, + onClickCharacter: { offset in + Task { try await viewModel.startPlayback(from: offset) } + }, + scrollToOffset: scrollToChapterOffset + ) + } } private func loadBook() async { diff --git a/Vorleser-macOS/PagedBookView.swift b/Vorleser-macOS/PagedBookView.swift new file mode 100644 index 0000000..3d8a112 --- /dev/null +++ b/Vorleser-macOS/PagedBookView.swift @@ -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? + 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) + } + } + } +} diff --git a/Vorleser.xcodeproj/project.pbxproj b/Vorleser.xcodeproj/project.pbxproj index f52411e..13ea559 100644 --- a/Vorleser.xcodeproj/project.pbxproj +++ b/Vorleser.xcodeproj/project.pbxproj @@ -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 = ""; }; E1D2DCB6321555456B400716 /* MacLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryView.swift; sourceTree = ""; }; + E1F2E07663FD305CD8BE14A3 /* PagedBookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedBookView.swift; sourceTree = ""; }; E67F95D90A3116255C29F5BF /* MacReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacReaderView.swift; sourceTree = ""; }; F659E46345F099B2D20F426C /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = ""; }; + F8294FBF66215BF4082A2E1B /* PagedBookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedBookView.swift; sourceTree = ""; }; FD5E22A3829AA6729CFA9FA8 /* BookTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookTextView.swift; sourceTree = ""; }; /* 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;