From 99bdc98895092638ce3775f01ff06690e94cb422 Mon Sep 17 00:00:00 2001 From: shenlei Date: Fri, 3 Jul 2026 16:12:11 +0900 Subject: [PATCH] Fix reader interaction and web resource handling --- .../RDEPUBResourceURLSchemeHandler.swift | 10 +- ...PUBReaderController+ContentDelegates.swift | 8 ++ ...PUBTextContentInteractionCoordinator.swift | 7 ++ .../TextPage/RDEPUBTextContentView.swift | 40 ++++++- .../ReaderView/RDReaderView+ToolView.swift | 6 + .../ReaderView/RDReaderView.swift | 111 +++++++++++++++++- 6 files changed, 171 insertions(+), 11 deletions(-) diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift b/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift index eeb05fe..e456ac3 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift @@ -156,6 +156,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler } Self.recordInMemoryResponse() RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished") + return } catch { guard self.isTaskActive(taskID) else { DispatchQueue.main.async { @@ -207,14 +208,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler } let chunkSize = 65_536 - var streamingCompleted = false defer { fileHandle.closeFile() - if !streamingCompleted { - // Task was cancelled during streaming; didFailWithError already sent above - // or will be sent by the guard check below. No need to send again. - } - self.clearTask(taskID) } while true { guard self.isTaskActive(taskID) else { @@ -222,6 +217,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler DispatchQueue.main.async { urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) } + self.clearTask(taskID) return } let data = fileHandle.readData(ofLength: chunkSize) @@ -230,7 +226,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler urlSchemeTask.didReceive(data) } } - streamingCompleted = true self.dispatchTaskSuccessCallback( taskID: taskID, urlSchemeTask: urlSchemeTask @@ -265,6 +260,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler return } callback() + self.clearTask(taskID) } } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift index 8622a69..d49632d 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift @@ -9,6 +9,10 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { return } + guard readerView.pageContentView(pageNum: readerView.currentPage) === contentView else { + return + } + let currentPage = activePages[readerView.currentPage] guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else { return @@ -89,6 +93,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { } func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) { + RDReaderTapDebug.log( + "ReaderController.textContentTap", + "delegate received reader tap from contentView=\(RDReaderTapDebug.describe(contentView)) point=\(RDReaderTapDebug.describe(point)) currentPage=\(readerView.currentPage)" + ) readerView.handleContentTap(at: point, in: contentView) } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift index 9d18b49..d6714bb 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift @@ -219,9 +219,16 @@ final class RDEPUBTextContentInteractionCoordinator: NSObject { private func updateSelectionInteractionState(_ state: SelectionInteractionState) { let previousTapSuppressed = interactionState != .idle let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState) + let previousState = interactionState interactionState = state let currentTapSuppressed = interactionState != .idle let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState) + if previousState != state { + RDReaderTapDebug.log( + "TextContentInteraction.state", + "transition \(previousState) -> \(state) tapSuppressed=\(currentTapSuppressed) pagingSuppressed=\(currentPagingSuppressed)" + ) + } if previousTapSuppressed != currentTapSuppressed { dependencies.selectionTapSuppressionDidChange(currentTapSuppressed) } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift index 45d9e44..fb85a4b 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift @@ -727,6 +727,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader || interactionCoordinator.interactionState != .idle guard isUserInteractionEnabled != shouldEnableInteraction else { return } isUserInteractionEnabled = shouldEnableInteraction + RDReaderTapDebug.log( + "TextContentView.interactionAvailability", + "updated isUserInteractionEnabled=\(shouldEnableInteraction) currentPage=\(currentPage?.absolutePageIndex ?? -1) loading=\(loadingSpinner.isAnimating) selecting=\(selectionController.isSelecting) hasSelection=\(selectionController.hasActiveSelection) state=\(interactionCoordinator.interactionState)" + ) } private func updateStaticGestureAvailability() { @@ -737,24 +741,36 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader guard longPressChanged || tapChanged else { return } longPressGestureRecognizer.isEnabled = shouldEnableLongPress tapGestureRecognizer.isEnabled = shouldEnableTap + RDReaderTapDebug.log( + "TextContentView.gestureAvailability", + "updated longPressEnabled=\(shouldEnableLongPress) tapEnabled=\(shouldEnableTap) hasInteractiveTextContent=\(hasInteractiveTextContent) loading=\(loadingSpinner.isAnimating) currentPage=\(currentPage?.absolutePageIndex ?? -1)" + ) } @objc private func handleTap(_ gesture: UITapGestureRecognizer) { let point = gesture.location(in: overlayView) + RDReaderTapDebug.log( + "TextContentView.handleTap", + "received point=\(RDReaderTapDebug.describe(point)) currentPage=\(currentPage?.absolutePageIndex ?? -1) selectionState=\(interactionCoordinator.interactionState) hasSelection=\(currentSelection != nil) loading=\(loadingSpinner.isAnimating)" + ) if let renderView = coreTextRenderView { let renderPoint = gesture.location(in: renderView) if renderView.selectionHandle(at: renderPoint) != nil { + RDReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle") return } if currentSelection != nil { if renderView.selectionContains(renderPoint) { + RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu") showSelectionMenuIfNeeded() return } + RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection") clearSelection() return } } else if currentSelection != nil { + RDReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection") clearSelection() return } @@ -766,6 +782,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader // Footnote attachments carry their note text in alt/accessibility metadata. // Prefer that semantic text over image preview even if attachment kind metadata is incomplete. if let footnoteText = attachmentText(at: point), kind == .footnote || !footnoteText.isEmpty { + RDReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap") delegate?.textContentView( self, didActivateAttachmentText: footnoteText, @@ -777,12 +794,14 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader // Regular image attachments: present image viewer if let image = imageFromPage(attachment: attachment, page: page) { let altText = attachmentText(at: point) + RDReaderTapDebug.log("TextContentView.handleTap", "resolved image attachment tap altTextPresent=\(altText?.isEmpty == false)") delegate?.textContentView(self, didActivateImage: image, sourceRect: sourceRect, altText: altText) return } } if let attachmentText = attachmentText(at: point), let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) { + RDReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap") delegate?.textContentView( self, didActivateAttachmentText: attachmentText, @@ -793,9 +812,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader } guard let highlight = highlight(at: point), let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { + RDReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate") delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView)) return } + RDReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)") delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect) } @@ -935,26 +956,36 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader func shouldSuppressReaderTap(at point: CGPoint) -> Bool { #if canImport(DTCoreText) if interactionCoordinator.interactionState == .selectionPending { + RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending") return true } guard let renderView = coreTextRenderView else { + RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil") return false } let renderPoint = convert(point, to: renderView) if renderView.selectionHandle(at: renderPoint) != nil { + RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle") return true } if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) { + RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection") return true } switch interactionCoordinator.interactionState { case .idle: + RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle") return false case .selectionPending, .selecting, .selectionActive, .adjustingHandle: + RDReaderTapDebug.log( + "TextContentView.shouldSuppressReaderTap", + "return true because interactionState=\(interactionCoordinator.interactionState)" + ) return true } #else + RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable") return false #endif } @@ -1003,16 +1034,23 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader ) -> Bool { if gestureRecognizer === tapGestureRecognizer { guard let renderView = coreTextRenderView else { + RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil") return true } let point = touch.location(in: renderView) if renderView.selectionHandle(at: point) != nil { + RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle") return false } } - return interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch) + let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch) + RDReaderTapDebug.log( + "TextContentView.gestureShouldReceive", + "gesture=\(type(of: gestureRecognizer)) touchView=\(RDReaderTapDebug.describe(touch.view)) result=\(result)" + ) + return result } func gestureRecognizer( diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift b/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift index d56951f..e61d5b4 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift @@ -6,6 +6,10 @@ extension RDReaderView { func tapCenter() { refreshToolViewsFromProviderIfNeeded() isShowToolView = !isShowToolView + RDReaderTapDebug.log( + "ReaderView.tapCenter", + "toggle toolView visible=\(isShowToolView) top=\(RDReaderTapDebug.describe(topToolView)) bottom=\(RDReaderTapDebug.describe(bottomToolView))" + ) if isShowToolView { if let topToolView = topToolView { installToolViewIfNeeded(topToolView, position: .top) @@ -27,6 +31,7 @@ extension RDReaderView { collectionView.isUserInteractionEnabled = false pageViewController.view.isUserInteractionEnabled = false + RDReaderTapDebug.log("ReaderView.tapCenter", "disabled collectionView/pageViewController interaction while toolView is visible") } else { if let topToolView = topToolView { UIView.animate(withDuration: toolViewAnimationDuration, animations: { @@ -46,6 +51,7 @@ extension RDReaderView { collectionView.isUserInteractionEnabled = true pageViewController.view.isUserInteractionEnabled = true + RDReaderTapDebug.log("ReaderView.tapCenter", "re-enabled collectionView/pageViewController interaction after hiding toolView") } onToolViewVisibilityChanged?(isShowToolView) } diff --git a/Sources/RDReaderView/ReaderView/RDReaderView.swift b/Sources/RDReaderView/ReaderView/RDReaderView.swift index 9d6c1e8..05a9d72 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView.swift @@ -1,6 +1,53 @@ import UIKit +enum RDReaderTapDebug { + + private static let enabledArguments: Set = [ + "--demo-tap-debug", + "--demo-reader-interaction-debug" + ] + + static var isEnabled: Bool { +#if DEBUG + return true +#else + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains(where: { enabledArguments.contains($0) }) { + return true + } + + let environment = ProcessInfo.processInfo.environment + return environment["RDREADER_TAP_DEBUG"] == "1" +#endif + } + + static func log(_ scope: String, _ message: String) { + guard isEnabled else { return } + let threadRole = Thread.isMainThread ? "main" : "bg" + let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown" + print("[RDReaderTap][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)") + } + + static func describe(_ point: CGPoint) -> String { + "(\(format(point.x)), \(format(point.y)))" + } + + static func describe(_ rect: CGRect) -> String { + "(x:\(format(rect.origin.x)), y:\(format(rect.origin.y)), w:\(format(rect.size.width)), h:\(format(rect.size.height)))" + } + + static func describe(_ view: UIView?) -> String { + guard let view else { return "nil" } + let identifier = view.accessibilityIdentifier ?? "nil" + return "\(type(of: view))(addr=\(Unmanaged.passUnretained(view).toOpaque()), id=\(identifier))" + } + + private static func format(_ value: CGFloat) -> String { + String(format: "%.1f", value) + } +} + public class RDReaderView: UIView { enum TapEvent { @@ -524,10 +571,16 @@ public class RDReaderView: UIView { @objc private func tapAction(tap: UITapGestureRecognizer) { let point = tap.location(in: tap.view) let hitView = hitTest(point, with: nil) + RDReaderTapDebug.log( + "ReaderView.tapAction", + "received point=\(RDReaderTapDebug.describe(point)) hitView=\(RDReaderTapDebug.describe(hitView)) display=\(currentDisplayType) currentPage=\(currentPage) toolVisible=\(isShowToolView) transitioning=\(isPageCurlTransitioning)" + ) if containsTextContentView(in: hitView) { + RDReaderTapDebug.log("ReaderView.tapAction", "ignored because hitView is inside text content view") return } if shouldSuppressChromeToggle(for: hitView, point: point) { + RDReaderTapDebug.log("ReaderView.tapAction", "ignored because chrome toggle is suppressed") return } handleResolvedTap(at: point, hitView: hitView, in: tap.view) @@ -535,13 +588,24 @@ public class RDReaderView: UIView { private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool { if selectionTapSuppressedContentViews.allObjects.isEmpty == false { + RDReaderTapDebug.log( + "ReaderView.suppressChromeToggle", + "suppressed by active selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)" + ) return true } var currentView = hitView while let view = currentView { if let textContentView = view as? RDEPUBTextContentView { let localPoint = convert(point, to: textContentView) - return textContentView.shouldSuppressReaderTap(at: localPoint) + let shouldSuppress = textContentView.shouldSuppressReaderTap(at: localPoint) + if shouldSuppress { + RDReaderTapDebug.log( + "ReaderView.suppressChromeToggle", + "suppressed by text content view=\(RDReaderTapDebug.describe(textContentView)) localPoint=\(RDReaderTapDebug.describe(localPoint))" + ) + } + return shouldSuppress } currentView = view.superview } @@ -566,6 +630,10 @@ public class RDReaderView: UIView { } else { selectionTapSuppressedContentViews.remove(contentView) } + RDReaderTapDebug.log( + "ReaderView.selectionTapSuppression", + "contentView=\(RDReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionTapSuppressedContentViews.allObjects.count)" + ) } func updateSelectionPagingSuppression(for contentView: UIView, isSuppressed: Bool) { @@ -576,18 +644,32 @@ public class RDReaderView: UIView { } else { selectionPagingSuppressedContentViews.remove(contentView) } + RDReaderTapDebug.log( + "ReaderView.selectionPagingSuppression", + "contentView=\(RDReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionPagingSuppressedContentViews.allObjects.count)" + ) updatePagingInteractionSuppression() } func handleContentTap(at point: CGPoint, in sourceView: UIView) { let localPoint = sourceView.convert(point, to: self) + RDReaderTapDebug.log( + "ReaderView.handleContentTap", + "forwarded from sourceView=\(RDReaderTapDebug.describe(sourceView)) sourcePoint=\(RDReaderTapDebug.describe(point)) localPoint=\(RDReaderTapDebug.describe(localPoint))" + ) handleResolvedTap(at: localPoint, hitView: nil, in: self) } private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) { if isShowToolView { - if let top = topToolView, isHitView(hitView, inside: top, point: point) { return } - if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { return } + if let top = topToolView, isHitView(hitView, inside: top, point: point) { + RDReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits top tool view") + return + } + if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { + RDReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits bottom tool view") + return + } } guard let viewFrame = tapView?.frame else { return } tapEvent = tapRegionHandler.resolveTapEvent( @@ -595,6 +677,10 @@ public class RDReaderView: UIView { viewFrame: viewFrame, isToolViewVisible: isShowToolView ) + RDReaderTapDebug.log( + "ReaderView.handleResolvedTap", + "resolved tapEvent=\(tapEvent) point=\(RDReaderTapDebug.describe(point)) viewFrame=\(RDReaderTapDebug.describe(viewFrame))" + ) } private func containsTextContentView(in hitView: UIView?) -> Bool { @@ -612,6 +698,10 @@ public class RDReaderView: UIView { let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false guard shouldSuppress != isPagingInteractionSuppressed else { return } isPagingInteractionSuppressed = shouldSuppress + RDReaderTapDebug.log( + "ReaderView.pagingSuppression", + "updated shouldSuppress=\(shouldSuppress) activeContentViews=\(activePagingSuppressionContentViews().count)" + ) setPagingInteractionEnabled(!shouldSuppress) } @@ -771,22 +861,37 @@ extension RDReaderView: UIGestureRecognizerDelegate { guard gestureRecognizer === tapGestureRecognizer else { return true } if selectionTapSuppressedContentViews.allObjects.isEmpty == false { + RDReaderTapDebug.log( + "ReaderView.gestureShouldReceive", + "return false because selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)" + ) return false } let point = touch.location(in: self) if containsTextContentView(in: touch.view) { + RDReaderTapDebug.log( + "ReaderView.gestureShouldReceive", + "return false because touch.view is inside text content view point=\(RDReaderTapDebug.describe(point)) touchView=\(RDReaderTapDebug.describe(touch.view))" + ) return false } if let topToolView, isHitView(touch.view, inside: topToolView, point: point) { + RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit topToolView") return false } if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) { + RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit bottomToolView") return false } if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) { + RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit searchBarView") return false } + RDReaderTapDebug.log( + "ReaderView.gestureShouldReceive", + "return true point=\(RDReaderTapDebug.describe(point)) touchView=\(RDReaderTapDebug.describe(touch.view))" + ) return true }