import UIKit extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { guard readerView.currentPage >= 0, activePages.indices.contains(readerView.currentPage) else { return } let currentPage = activePages[readerView.currentPage] guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else { return } persist(location: location) readingSession?.updateReadingContext( pageNumber: readerView.currentPage + 1, location: location, spineIndex: spineIndex, chapterIndex: currentPage.chapterIndex, bookIdentifier: currentBookIdentifier ) } func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) { if let selection { updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex)) } else { updateCurrentSelection(nil) } } func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { handleSelectionMenuAction(action, selection: currentSelection) } func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) { if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) { return } guard let readingSession, let pageNumber = readingSession.queueNavigation( to: location, relativeToSpineIndex: fromSpineIndex, bookIdentifier: currentBookIdentifier ) else { return } readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true) } func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) { delegate?.epubReader(self, didActivateExternalLink: url) openExternalURLIfAllowed(url) } func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) { #if DEBUG print("EPUB JS Error: \(message)") #endif } } extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) { guard let selection else { updateCurrentSelection(nil) return } updateCurrentSelection(selection) } func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) { readerView.handleContentTap(at: point, in: contentView) } func textContentView( _ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection? ) { handleSelectionMenuAction(action, selection: selection ?? currentSelection) contentView.clearSelection() } func textContentView( _ contentView: RDEPUBTextContentView, didActivateAttachmentText text: String, sourceRect: CGRect, sourcePoint: CGPoint ) { presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint) } func textContentView( _ contentView: RDEPUBTextContentView, didRequestHighlightActions highlight: RDEPUBHighlight, sourceRect: CGRect ) { runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect) } func pageNumber(for location: RDEPUBLocation) -> Int? { if let publication, let bookPageMap = readerContext.bookPageMap, let spineIndex = readerContext.normalizedSpineIndex(for: location), let entry = bookPageMap.entry(forSpineIndex: spineIndex) { let normalizedLocation = publication.resourceResolver.normalizedLocation( location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? location let localPageIndex: Int if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) { let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry) localPageIndex = summary.pageRanges.firstIndex { let range = $0.nsRange return offset >= range.location && offset <= max(range.location + range.length - 1, range.location) } ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount) } else { localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount) } return bookPageMap.absolutePageIndex( spineIndex: spineIndex, localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0)) ).map { $0 + 1 } } if let textBook, let publication { if let anchor = location.rangeAnchor?.start { if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) { return page + 1 } } let normalizedLocation = publication.resourceResolver.normalizedLocation( location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? location return textBook.pageNumber( for: normalizedLocation, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier ) } return readingSession?.queueNavigation( to: location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) } func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? { if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) { let startOffset = resolvedPage.page.pageStartOffset let endOffset = max(startOffset, resolvedPage.page.pageEndOffset) let chapterData = makeRuntimeChapterData(from: resolvedPage) let location = chapterData.location( for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)), bookIdentifier: currentBookIdentifier ) if let publication { return publication.resourceResolver.normalizedLocation( location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? location } return location } guard let textBook, let page = textBook.page(at: pageNumber) else { return nil } let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier) ?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier) guard let location else { return nil } if let publication { return publication.resourceResolver.normalizedLocation( location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? location } return location } func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) { if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) { readingSession?.updateReadingContext( pageNumber: pageNumber, location: location, spineIndex: resolvedPage.page.spineIndex, chapterIndex: resolvedPage.chapterIndex, bookIdentifier: currentBookIdentifier ) return } guard let textBook, let page = textBook.page(at: pageNumber) else { readingSession?.transition(to: .idle) return } readingSession?.updateReadingContext( pageNumber: pageNumber, location: location, spineIndex: page.spineIndex, chapterIndex: page.chapterIndex, bookIdentifier: currentBookIdentifier ) } func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot { let chapters = textBook.chapterInfos let pages = textBook.pages.map { EPUBPage( spineIndex: $0.spineIndex, chapterIndex: $0.chapterIndex, pageIndexInChapter: $0.pageIndexInChapter, totalPagesInChapter: $0.totalPagesInChapter, chapterTitle: $0.chapterTitle, fixedSpread: nil ) } return (pages, chapters) } private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? { runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1) } func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int { if let spineIndex = readerContext.normalizedSpineIndex(for: location), let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex), let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) { return offset } if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi), let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset { return cfiOffset } if let anchor = location.rangeAnchor?.start { return anchor.chapterOffset } if let fragment = location.fragment, let offset = fallbackEntry.fragmentOffsets[fragment] { return offset } return 0 } func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int { guard pageCount > 1 else { return 0 } return min( pageCount - 1, max(0, Int(round(location.navigationProgression * Double(pageCount - 1)))) ) } private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? { var bestID: String? var bestOffset = Int.min for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset { bestOffset = fragmentOffset bestID = fragmentID } return bestID } private func shouldAllowExternalURL(_ url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if delegate?.epubReader(self, shouldOpenExternalURL: url) == false { return false } return configuration.allowedExternalURLSchemes.contains(scheme) } private func openExternalURLIfAllowed(_ url: URL) { guard shouldAllowExternalURL(url) else { return } if configuration.requiresExternalLinkConfirmation { presentExternalLinkConfirmation(for: url) } else { UIApplication.shared.open(url, options: [:], completionHandler: nil) } } private func presentExternalLinkConfirmation(for url: URL) { let alert = UIAlertController( title: "打开外部链接", message: url.absoluteString, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in UIApplication.shared.open(url, options: [:], completionHandler: nil) }) present(alert, animated: true) } private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool { guard let publication else { return false } let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href let sourceLocation = currentVisibleLocation() let sourceCFI = sourceLocation?.cfi let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver) guard let note = resolver.resolveInternalLink( sourceHref: sourceHref, sourceCFI: sourceCFI, targetLocation: location, sourceLocation: sourceLocation, relativeToSpineIndex: fromSpineIndex ) else { return false } RDEPUBNotePopupCoordinator.present( note, from: self, onReturnToSource: { [weak self] in guard let self, let sourceLocation = note.sourceLocation else { return } self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true) }, onOpenNoteLocation: { [weak self] in guard let self else { return } self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true) } ) return true } private func navigateToLocation( _ location: RDEPUBLocation, relativeToSpineIndex spineIndex: Int?, animated: Bool ) { guard let pageNumber = readingSession?.queueNavigation( to: location, relativeToSpineIndex: spineIndex, bookIdentifier: currentBookIdentifier ) else { return } readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated) } private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) { hideAttachmentTooltipIfNeeded() let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds) overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] overlay.onBackgroundTap = { [weak self, weak overlay] in guard let self, let overlay else { return } self.dismissAttachmentTooltipOverlay(overlay) } let tooltip = RDEPUBAttachmentTooltipView() tooltip.alpha = 0 let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12 tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320)) let anchorRect = sourceView.convert(sourceRect, to: view) let rawAnchorPoint = sourceView.convert(sourcePoint, to: view) let anchorPoint = CGPoint( x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX), y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY) ) let verticalSpacing: CGFloat = 6 let tooltipSize = tooltip.frame.size let idealX = anchorPoint.x - tooltipSize.width / 2 let minX = horizontalPadding let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width) let originX = min(max(idealX, minX), maxX) let topSafeY = view.safeAreaInsets.top + 12 let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12 let availableSpaceAbove = anchorRect.minY - topSafeY let availableSpaceBelow = bottomSafeY - anchorRect.maxY let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top let originY: CGFloat switch tooltipPlacement { case .bottom: originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing) case .top: originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing) } let arrowTipX = min( max(anchorPoint.x - originX, tooltip.minimumArrowX), tooltipSize.width - tooltip.minimumArrowX ) tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement) tooltip.frame.origin = CGPoint(x: originX, y: originY) overlay.addSubview(tooltip) view.addSubview(overlay) UIView.animate(withDuration: 0.2) { tooltip.alpha = 1 } DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in guard let self, let overlay else { return } self.dismissAttachmentTooltipOverlay(overlay) } } private func hideAttachmentTooltipIfNeeded() { view.subviews .compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView } .forEach { $0.removeFromSuperview() } } private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) { UIView.animate(withDuration: 0.18, animations: { overlay.tooltipView?.alpha = 0 }, completion: { _ in overlay.removeFromSuperview() }) } } private extension RDEPUBReaderController { func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData { let textChapter = RDEPUBTextChapter( chapterIndex: resolvedPage.chapterIndex, spineIndex: resolvedPage.chapter.spineIndex, href: resolvedPage.chapter.href, title: resolvedPage.chapter.title, attributedContent: resolvedPage.chapter.typesetAttributedString, fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets, cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap, pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason), pages: resolvedPage.chapter.pages ) return RDEPUBChapterData( chapter: textChapter, indexTable: RDEPUBTextIndexTable(chapters: [textChapter]) ) } } private final class RDEPUBAttachmentTooltipOverlayView: UIView { var onBackgroundTap: (() -> Void)? var tooltipView: RDEPUBAttachmentTooltipView? { subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first } override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tapGesture.cancelsTouchesInView = false addGestureRecognizer(tapGesture) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func handleTap(_ gesture: UITapGestureRecognizer) { let point = gesture.location(in: self) guard let tooltipView else { onBackgroundTap?() return } if !tooltipView.frame.contains(point) { onBackgroundTap?() } } } private final class RDEPUBAttachmentTooltipView: UIView { enum ArrowPlacement { case top case bottom } private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20) private let arrowSize = CGSize(width: 20, height: 10) private let cornerRadius: CGFloat = 18 private(set) var minimumArrowX: CGFloat = 28 private var arrowTipX: CGFloat? private var arrowPlacement: ArrowPlacement = .bottom private let textLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 label.textColor = .white label.font = .systemFont(ofSize: 16, weight: .regular) label.lineBreakMode = .byWordWrapping return label }() private let shapeLayer = CAShapeLayer() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false layer.addSublayer(shapeLayer) addSubview(textLabel) accessibilityIdentifier = "epub.reader.attachment.tooltip" } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() shapeLayer.frame = bounds shapeLayer.path = bubblePath(in: bounds).cgPath shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0) let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0) let labelFrame = bounds.inset(by: UIEdgeInsets( top: topInset, left: contentInsets.left, bottom: bottomInset, right: contentInsets.right )) textLabel.frame = labelFrame } func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) { arrowTipX = value arrowPlacement = placement setNeedsLayout() } func configure(text: String, maxWidth: CGFloat) { textLabel.text = text let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120) let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude)) frame.size = CGSize( width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right), height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height ) setNeedsLayout() layoutIfNeeded() } private func bubblePath(in rect: CGRect) -> UIBezierPath { let bubbleRect: CGRect switch arrowPlacement { case .bottom: bubbleRect = CGRect( x: rect.minX, y: rect.minY, width: rect.width, height: rect.height - arrowSize.height ) case .top: bubbleRect = CGRect( x: rect.minX, y: rect.minY + arrowSize.height, width: rect.width, height: rect.height - arrowSize.height ) } let arrowMidX = min( max(arrowTipX ?? bubbleRect.midX, minimumArrowX), bubbleRect.width - minimumArrowX ) let arrowHalfWidth = arrowSize.width / 2 let path = UIBezierPath() switch arrowPlacement { case .bottom: path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) path.addArc( withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), radius: cornerRadius, startAngle: -.pi / 2, endAngle: 0, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) path.addArc( withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), radius: cornerRadius, startAngle: 0, endAngle: .pi / 2, clockwise: true ) path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY)) path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height)) path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY)) path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) path.addArc( withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), radius: cornerRadius, startAngle: .pi / 2, endAngle: .pi, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) path.addArc( withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), radius: cornerRadius, startAngle: .pi, endAngle: -.pi / 2, clockwise: true ) case .top: path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY)) path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height)) path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY)) path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) path.addArc( withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), radius: cornerRadius, startAngle: -.pi / 2, endAngle: 0, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) path.addArc( withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), radius: cornerRadius, startAngle: 0, endAngle: .pi / 2, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) path.addArc( withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), radius: cornerRadius, startAngle: .pi / 2, endAngle: .pi, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) path.addArc( withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), radius: cornerRadius, startAngle: .pi, endAngle: -.pi / 2, clockwise: true ) } path.close() return path } }