将 PDF 划线/注释交互完整下沉到 SDK 库

- RDPDFReaderPageView:完整页面视图移入 SDK(缩放画布、文字选择层、
  选区放大镜、无障碍视口),并内置与 EPUB 一致的两套系统编辑菜单:
  选区菜单(复制/划线/注释)、已有划线菜单(复制/注释|删除注释/删除划线)
- RDPDFReaderPageViewDelegate:宿主只通过代理做数据持久化与打开业务界面
- 修复划线命中判定:点击点先归一化再与标注比例坐标比较
- 修复 tappedHighlight 残留导致选区菜单项被错误过滤的问题
- 主题应用改为纯颜色入参,SDK 不依赖 EPUB 主题类型
- RDPDFReaderHighlightOverlayView 恢复为纯绘制层,点击交互统一由页面视图处理
- Demo 删除 PDFDemoPageView(约 290 行),改为实现代理回调

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-07-15 10:33:43 +09:00
parent 5a19cfde14
commit 1bde945d36
3 changed files with 350 additions and 394 deletions

View File

@ -164,9 +164,9 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderDataSource
func pageCountOfReaderView(readerView: RDPDFReaderView) -> Int { document.pageCount } func pageCountOfReaderView(readerView: RDPDFReaderView) -> Int { document.pageCount }
func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView { func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView {
let page = PDFDemoPageView() let page = RDPDFReaderPageView()
page.image = renderedImage(pageNum) page.image = renderedImage(pageNum)
page.apply(theme: currentThemePreset.theme) applyTheme(to: page)
configure(page, for: pageNum) configure(page, for: pageNum)
return page return page
} }
@ -204,34 +204,22 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderDataSource
refreshDemoState() refreshDemoState()
} }
private func configure(_ page: PDFDemoPageView, for pageIndex: Int) { private func configure(_ page: RDPDFReaderPageView, for pageIndex: Int) {
page.configureTextLayer( page.configureTextLayer(
pageIndex: pageIndex, pageIndex: pageIndex,
textRuns: textRuns(for: pageIndex), textRuns: textRuns(for: pageIndex),
textSource: imageTextSource.annotationSource, textSource: imageTextSource.annotationSource,
annotations: annotations(for: pageIndex) annotations: annotations(for: pageIndex)
) )
page.onHighlight = { [weak self] selection, color in page.delegate = self
self?.addAnnotation(from: selection, pageIndex: pageIndex, color: color, note: nil) }
}
page.onRequestNote = { [weak self] selection in private func applyTheme(to page: RDPDFReaderPageView) {
self?.showAnnotationEditor(for: selection, pageIndex: pageIndex) let theme = currentThemePreset.theme
} page.applyTheme(
page.onOpenAnnotation = { [weak self] annotation in contentBackgroundColor: theme.contentBackgroundColor,
self?.showAnnotationEditor(editing: annotation) surroundingBackgroundColor: theme.toolBackgroundColor
} )
page.onCopyText = { [weak self] text in
self?.copiedText = text
self?.refreshDemoState()
}
page.onSelectionChanged = { [weak self] selection in
guard let self, self.readerView.currentPage == pageIndex else { return }
self.activeSelection = selection
self.refreshDemoState()
}
page.onExistingHighlightMenuAction = { [weak self] action, annotation in
self?.handleExistingHighlightMenuAction(action, annotation: annotation)
}
} }
private func textRuns(for pageIndex: Int) -> [RDPDFReaderTextRun] { private func textRuns(for pageIndex: Int) -> [RDPDFReaderTextRun] {
@ -290,7 +278,7 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderDataSource
} }
private func refreshVisiblePageContent(for pageIndex: Int) { private func refreshVisiblePageContent(for pageIndex: Int) {
guard let page = readerView.pageContentView(pageNum: pageIndex) as? PDFDemoPageView else { return } guard let page = readerView.pageContentView(pageNum: pageIndex) as? RDPDFReaderPageView else { return }
page.configureTextLayer( page.configureTextLayer(
pageIndex: pageIndex, pageIndex: pageIndex,
textRuns: textRuns(for: pageIndex), textRuns: textRuns(for: pageIndex),
@ -365,7 +353,7 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderDataSource
break break
case .deleteUnderline: case .deleteUnderline:
do { do {
try annotationStore.deleteAnnotation(withID: annotation.id) _ = try annotationStore.deleteAnnotation(id: annotation.id)
annotationPersistenceError = nil annotationPersistenceError = nil
refreshVisiblePageContent(for: annotation.pageIndex) refreshVisiblePageContent(for: annotation.pageIndex)
refreshDemoState() refreshDemoState()
@ -378,7 +366,7 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderDataSource
do { do {
var updated = annotation var updated = annotation
updated.note = nil updated.note = nil
try annotationStore.updateAnnotation(updated) _ = try annotationStore.updateAnnotation(updated)
annotationPersistenceError = nil annotationPersistenceError = nil
refreshVisiblePageContent(for: annotation.pageIndex) refreshVisiblePageContent(for: annotation.pageIndex)
refreshDemoState() refreshDemoState()
@ -672,8 +660,8 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderDataSource
themedImageCache.removeAll() themedImageCache.removeAll()
if refreshCurrentPage, readerView.currentPage >= 0 { if refreshCurrentPage, readerView.currentPage >= 0 {
if let pageView = readerView.pageContentView(pageNum: readerView.currentPage) as? PDFDemoPageView { if let pageView = readerView.pageContentView(pageNum: readerView.currentPage) as? RDPDFReaderPageView {
pageView.apply(theme: theme) applyTheme(to: pageView)
pageView.image = renderedImage(readerView.currentPage) pageView.image = renderedImage(readerView.currentPage)
} }
refreshVisiblePageContent(for: readerView.currentPage) refreshVisiblePageContent(for: readerView.currentPage)
@ -787,6 +775,42 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderDataSource
} }
} }
// MARK: - RDPDFReaderPageViewDelegate
// /线/ SDK
extension PDFDemoReaderViewController: RDPDFReaderPageViewDelegate {
func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?) {
guard readerView.currentPage == pageView.pageIndex else { return }
activeSelection = selection
refreshDemoState()
}
func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {
copiedText = text
refreshDemoState()
}
func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) {
addAnnotation(from: selection, pageIndex: pageView.pageIndex, color: color, note: nil)
}
func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) {
showAnnotationEditor(for: selection, pageIndex: pageView.pageIndex)
}
func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) {
showAnnotationEditor(editing: annotation)
}
func pageView(
_ pageView: RDPDFReaderPageView,
didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction,
highlight: RDPDFReaderAnnotation
) {
handleExistingHighlightMenuAction(action, annotation: highlight)
}
}
private final class PDFDemoNavigationViewController: UIViewController { private final class PDFDemoNavigationViewController: UIViewController {
private let handleView = UIView() private let handleView = UIView()
private let closeButton = UIButton(type: .system) private let closeButton = UIButton(type: .system)
@ -864,296 +888,6 @@ private final class PDFDemoNavigationViewController: UIViewController {
} }
} }
private final class PDFDemoPageView: UIView, RDPDFReaderPageInteractable, UIGestureRecognizerDelegate {
private let zoomView = RDPDFZoomablePageView()
private let textLayer = RDPDFReaderImageTextLayerView()
private let contentAccessibilityView = UIView()
private let selectionLoupe = RDPDFReaderSelectionLoupeView()
var image: UIImage? { didSet { zoomView.image = image } }
var readerContentTapHandler: ((CGPoint) -> Void)?
var readerZoomStateChangedHandler: ((Bool) -> Void)?
var readerSelectionStateChangedHandler: ((Bool) -> Void)?
var onSelectionChanged: ((RDPDFReaderImageTextSelection?) -> Void)?
var onCopyText: ((String) -> Void)?
var onHighlight: ((RDPDFReaderImageTextSelection, String) -> Void)?
var onRequestNote: ((RDPDFReaderImageTextSelection) -> Void)?
var onOpenAnnotation: ((RDPDFReaderAnnotation) -> Void)?
var onExistingHighlightMenuAction: ((RDPDFReaderExistingHighlightMenuAction, RDPDFReaderAnnotation) -> Void)?
var readerIsZoomed: Bool { zoomView.isZoomed }
var readerHasActiveTextSelection: Bool { textLayer.selectedSelection != nil }
func configureTextLayer(
pageIndex: Int,
textRuns: [RDPDFReaderTextRun],
textSource: RDPDFReaderAnnotationSource,
annotations: [RDPDFReaderAnnotation]
) {
textLayer.pageIndex = pageIndex
textLayer.textSource = textSource
textLayer.textRuns = textRuns
textLayer.annotations = annotations
updateAccessibilityViewport()
}
func readerSetInternalGesturesEnabled(_ enabled: Bool) { zoomView.setInternalGesturesEnabled(enabled) }
func readerClearTextSelection() { textLayer.clearSelection() }
func readerBeginExternalPinch(at point: CGPoint) { zoomView.beginExternalPinch(at: convert(point, to: zoomView)) }
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {
zoomView.updateExternalPinch(scale: scale, at: convert(point, to: zoomView))
}
func readerEndExternalPinch() { zoomView.endExternalPinch() }
func readerBeginExternalPan() { zoomView.beginExternalPan() }
func readerUpdateExternalPan(translation: CGPoint) { zoomView.updateExternalPan(translation: translation) }
func readerEndExternalPan() { zoomView.endExternalPan() }
override init(frame: CGRect) {
super.init(frame: frame)
isAccessibilityElement = false
zoomView.frame = bounds
zoomView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
zoomView.zoomStateChanged = { [weak self] zoomed in
self?.readerZoomStateChangedHandler?(zoomed)
self?.updateAccessibilityViewport()
}
zoomView.viewportChanged = { [weak self] _, _ in self?.updateAccessibilityViewport() }
addSubview(zoomView)
textLayer.frame = zoomView.contentView.bounds
textLayer.autoresizingMask = [.flexibleWidth, .flexibleHeight]
textLayer.isAccessibilityElement = true
textLayer.onSelectionChanged = { [weak self] selection in
self?.handleSelectionChange(selection)
}
textLayer.onInteractionStateChanged = { [weak self] state in
self?.handleSelectionInteractionStateChange(state)
}
textLayer.onSelectionFocusChanged = { [weak self] focusPoint in
self?.updateSelectionLoupe(focusPoint)
}
zoomView.contentView.addSubview(textLayer)
contentAccessibilityView.frame = bounds
contentAccessibilityView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentAccessibilityView.backgroundColor = .clear
contentAccessibilityView.isUserInteractionEnabled = false
contentAccessibilityView.isAccessibilityElement = true
contentAccessibilityView.accessibilityIdentifier = "epub.reader.content"
contentAccessibilityView.accessibilityLabel = "PDF 阅读页面"
addSubview(contentAccessibilityView)
addSubview(selectionLoupe)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.delegate = self
tap.require(toFail: zoomView.doubleTapZoomGestureRecognizer)
tap.require(toFail: textLayer.longPressGestureRecognizer)
tap.require(toFail: textLayer.selectionHandlePanGestureRecognizer)
tap.cancelsTouchesInView = false
addGestureRecognizer(tap)
updateAccessibilityViewport()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
zoomView.layoutIfNeeded()
textLayer.frame = zoomView.contentView.bounds
}
func apply(theme: RDEPUBReaderTheme) {
backgroundColor = theme.contentBackgroundColor
zoomView.backgroundColor = theme.toolBackgroundColor
zoomView.contentView.backgroundColor = theme.contentBackgroundColor
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
if textLayer.selectedSelection != nil {
textLayer.clearSelection()
return
}
let textLayerPoint = gesture.location(in: textLayer)
if let annotation = textLayer.note(at: textLayerPoint) {
onOpenAnnotation?(annotation)
return
}
if let highlight = highlightAt(textLayerPoint) {
showExistingHighlightMenu(for: highlight, at: gesture.location(in: self))
return
}
readerContentTapHandler?(gesture.location(in: self))
}
private func highlightAt(_ point: CGPoint) -> RDPDFReaderAnnotation? {
let currentPageAnnotations = textLayer.annotations.filter { $0.pageIndex == textLayer.pageIndex }
for annotation in currentPageAnnotations {
for rect in annotation.normalizedRects {
if rect.contains(point) {
return annotation
}
}
}
return nil
}
// MARK: - EPUB
override var canBecomeFirstResponder: Bool { true }
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if tappedHighlight != nil {
return action == #selector(rd_copy_highlight(_:))
|| action == #selector(rd_delete_highlight(_:))
|| action == #selector(rd_annotate_highlight(_:))
|| action == #selector(rd_delete_annotation(_:))
}
guard let selection = textLayer.selectedSelection else { return false }
switch action {
case #selector(rd_copy(_:)):
return selection.canCopyText
case #selector(rd_highlight(_:)), #selector(rd_annotate(_:)):
return true
default:
return false
}
}
private func handleSelectionChange(_ selection: RDPDFReaderImageTextSelection?) {
if selection == nil {
hideSelectionMenu()
}
readerSelectionStateChangedHandler?(selection != nil)
onSelectionChanged?(selection)
}
private func handleSelectionInteractionStateChange(_ state: RDPDFReaderSelectionInteractionState) {
switch state {
case .selectionActive:
showSelectionMenu()
case .idle, .selecting, .adjustingHandle:
hideSelectionMenu()
}
}
private func showSelectionMenu() {
guard textLayer.selectedSelection != nil,
let anchorRect = textLayer.menuAnchorRect() else {
return
}
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "复制", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "注释", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(convert(anchorRect, from: textLayer), in: self)
menuController.setMenuVisible(true, animated: true)
}
private func hideSelectionMenu() {
UIMenuController.shared.setMenuVisible(false, animated: true)
}
private func updateSelectionLoupe(_ focusPoint: CGPoint?) {
guard let focusPoint else {
selectionLoupe.dismiss()
return
}
selectionLoupe.present(
sourceView: zoomView.contentView,
focusPoint: textLayer.convert(focusPoint, to: zoomView.contentView),
hostBounds: bounds,
targetPoint: convert(focusPoint, from: textLayer)
)
}
@objc private func rd_copy(_ sender: Any?) {
guard let selection = textLayer.selectedSelection,
selection.canCopyText,
let text = selection.text?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else { return }
UIPasteboard.general.string = text
onCopyText?(text)
textLayer.clearSelection()
}
@objc private func rd_highlight(_ sender: Any?) {
guard let selection = textLayer.selectedSelection else { return }
// EPUB
onHighlight?(selection, "#F8E16C")
textLayer.clearSelection()
}
@objc private func rd_annotate(_ sender: Any?) {
guard let selection = textLayer.selectedSelection else { return }
onRequestNote?(selection)
textLayer.clearSelection()
}
// MARK: - EPUB
private var tappedHighlight: RDPDFReaderAnnotation?
private func showExistingHighlightMenu(for annotation: RDPDFReaderAnnotation, at point: CGPoint) {
tappedHighlight = annotation
becomeFirstResponder()
var menuItems: [UIMenuItem] = [
UIMenuItem(title: "复制", action: #selector(rd_copy_highlight(_:)))
]
if annotation.note?.isEmpty == false {
menuItems.append(UIMenuItem(title: "删除注释", action: #selector(rd_delete_annotation(_:))))
} else {
menuItems.append(UIMenuItem(title: "注释", action: #selector(rd_annotate_highlight(_:))))
}
menuItems.append(UIMenuItem(title: "删除划线", action: #selector(rd_delete_highlight(_:))))
let menuController = UIMenuController.shared
menuController.menuItems = menuItems
menuController.setTargetRect(CGRect(x: point.x, y: point.y, width: 1, height: 1), in: self)
menuController.setMenuVisible(true, animated: true)
}
@objc private func rd_copy_highlight(_ sender: Any?) {
guard let highlight = tappedHighlight,
let text = highlight.selectedText?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else { return }
UIPasteboard.general.string = text
onExistingHighlightMenuAction?(.copy, highlight)
}
@objc private func rd_annotate_highlight(_ sender: Any?) {
guard let highlight = tappedHighlight else { return }
onExistingHighlightMenuAction?(.annotate, highlight)
}
@objc private func rd_delete_annotation(_ sender: Any?) {
guard let highlight = tappedHighlight else { return }
onExistingHighlightMenuAction?(.deleteAnnotation, highlight)
}
@objc private func rd_delete_highlight(_ sender: Any?) {
guard let highlight = tappedHighlight else { return }
onExistingHighlightMenuAction?(.deleteUnderline, highlight)
}
private func updateAccessibilityViewport() {
let offset = zoomView.contentOffset
contentAccessibilityView.accessibilityValue = String(
format: "zoom=%.2f offset=%.0f,%.0f",
zoomView.zoomScale,
offset.x,
offset.y
)
}
}
private final class PDFDemoSettingsViewController: UIViewController { private final class PDFDemoSettingsViewController: UIViewController {
var onBrightnessChange: ((CGFloat) -> Void)? var onBrightnessChange: ((CGFloat) -> Void)?
var onDisplayTypeChange: ((RDPDFReaderView.DisplayType) -> Void)? var onDisplayTypeChange: ((RDPDFReaderView.DisplayType) -> Void)?

View File

@ -1,32 +1,19 @@
import UIKit import UIKit
/// SDK /// SDK 线
/// 线 `RDPDFReaderPageView`
public final class RDPDFReaderHighlightOverlayView: UIView { public final class RDPDFReaderHighlightOverlayView: UIView {
public var highlights: [RDPDFReaderHighlight] = [] { didSet { setNeedsDisplay() } } public var highlights: [RDPDFReaderHighlight] = [] { didSet { setNeedsDisplay() } }
public var onHighlightTapped: ((RDPDFReaderHighlight, CGPoint) -> Void)?
public override init(frame: CGRect) { public override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
isOpaque = false isOpaque = false
backgroundColor = .clear backgroundColor = .clear
isUserInteractionEnabled = true isUserInteractionEnabled = false
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))))
} }
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: self)
let normalizedPoint = CGPoint(x: point.x / bounds.width, y: point.y / bounds.height)
for highlight in highlights {
if highlight.normalizedRect.contains(normalizedPoint) {
onHighlightTapped?(highlight, point)
return
}
}
}
public override func draw(_ rect: CGRect) { public override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return } guard let context = UIGraphicsGetCurrentContext() else { return }
context.clear(rect) context.clear(rect)

View File

@ -1,78 +1,267 @@
import UIKit import UIKit
/// PDF /// 宿/线/ SDK
/// EPUB RDEPUBTextContentView /// 宿
open class RDPDFReaderPageView: UIView, UIGestureRecognizerDelegate { /// EPUB `RDEPUBTextContentViewDelegate`
public protocol RDPDFReaderPageViewDelegate: AnyObject {
/// `nil` 宿
func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?)
/// SDK 宿
func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String)
/// 线宿
/// `configureTextLayer`
func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String)
/// 宿
func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection)
/// 宿/
func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation)
/// 线`.copy` SDK
/// /宿
func pageView(
_ pageView: RDPDFReaderPageView,
didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction,
highlight: RDPDFReaderAnnotation
)
}
public extension RDPDFReaderPageViewDelegate {
func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?) {}
func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {}
}
/// PDF
/// EPUB
/// - 线
/// - 线/线
public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIGestureRecognizerDelegate {
/// EPUB 线
public static let defaultHighlightColor = "#F8E16C"
public weak var delegate: RDPDFReaderPageViewDelegate? public weak var delegate: RDPDFReaderPageViewDelegate?
/// 宿PDF public var image: UIImage? { didSet { zoomView.image = image } }
public let contentView = UIView()
/// contentView /// `configureTextLayer`
public let textLayer = RDPDFReaderImageTextLayerView() public var pageIndex: Int { textLayer.pageIndex }
/// UI
public var contentAccessibilityIdentifier: String = "epub.reader.content" {
didSet { contentAccessibilityView.accessibilityIdentifier = contentAccessibilityIdentifier }
}
private let zoomView = RDPDFZoomablePageView()
private let textLayer = RDPDFReaderImageTextLayerView()
private let contentAccessibilityView = UIView()
private let selectionLoupe = RDPDFReaderSelectionLoupeView()
private var tappedHighlight: RDPDFReaderAnnotation? private var tappedHighlight: RDPDFReaderAnnotation?
// MARK: - RDPDFReaderPageInteractable
public var readerContentTapHandler: ((CGPoint) -> Void)?
public var readerZoomStateChangedHandler: ((Bool) -> Void)?
public var readerSelectionStateChangedHandler: ((Bool) -> Void)?
public var readerIsZoomed: Bool { zoomView.isZoomed }
public var readerHasActiveTextSelection: Bool { textLayer.selectedSelection != nil }
public func readerSetInternalGesturesEnabled(_ enabled: Bool) { zoomView.setInternalGesturesEnabled(enabled) }
public func readerClearTextSelection() { textLayer.clearSelection() }
public func readerBeginExternalPinch(at point: CGPoint) { zoomView.beginExternalPinch(at: convert(point, to: zoomView)) }
public func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {
zoomView.updateExternalPinch(scale: scale, at: convert(point, to: zoomView))
}
public func readerEndExternalPinch() { zoomView.endExternalPinch() }
public func readerBeginExternalPan() { zoomView.beginExternalPan() }
public func readerUpdateExternalPan(translation: CGPoint) { zoomView.updateExternalPan(translation: translation) }
public func readerEndExternalPan() { zoomView.endExternalPan() }
// MARK: -
public func configureTextLayer(
pageIndex: Int,
textRuns: [RDPDFReaderTextRun],
textSource: RDPDFReaderAnnotationSource,
annotations: [RDPDFReaderAnnotation]
) {
textLayer.pageIndex = pageIndex
textLayer.textSource = textSource
textLayer.textRuns = textRuns
textLayer.annotations = annotations
updateAccessibilityViewport()
}
/// SDK 宿
public func applyTheme(contentBackgroundColor: UIColor, surroundingBackgroundColor: UIColor) {
backgroundColor = contentBackgroundColor
zoomView.backgroundColor = surroundingBackgroundColor
zoomView.contentView.backgroundColor = contentBackgroundColor
}
// MARK: -
public override init(frame: CGRect) { public override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
setupViews() isAccessibilityElement = false
}
public required init?(coder: NSCoder) { zoomView.frame = bounds
super.init(coder: coder) zoomView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
setupViews() zoomView.zoomStateChanged = { [weak self] zoomed in
} self?.readerZoomStateChangedHandler?(zoomed)
self?.updateAccessibilityViewport()
}
zoomView.viewportChanged = { [weak self] _, _ in self?.updateAccessibilityViewport() }
addSubview(zoomView)
private func setupViews() { textLayer.frame = zoomView.contentView.bounds
addSubview(contentView)
contentView.frame = bounds
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
textLayer.frame = contentView.bounds
textLayer.autoresizingMask = [.flexibleWidth, .flexibleHeight] textLayer.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentView.addSubview(textLayer) textLayer.isAccessibilityElement = true
textLayer.onSelectionChanged = { [weak self] selection in
self?.handleSelectionChange(selection)
}
textLayer.onInteractionStateChanged = { [weak self] state in
self?.handleSelectionInteractionStateChange(state)
}
textLayer.onSelectionFocusChanged = { [weak self] focusPoint in
self?.updateSelectionLoupe(focusPoint)
}
zoomView.contentView.addSubview(textLayer)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) contentAccessibilityView.frame = bounds
addGestureRecognizer(tapGesture) contentAccessibilityView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentAccessibilityView.backgroundColor = .clear
contentAccessibilityView.isUserInteractionEnabled = false
contentAccessibilityView.isAccessibilityElement = true
contentAccessibilityView.accessibilityIdentifier = contentAccessibilityIdentifier
contentAccessibilityView.accessibilityLabel = "PDF 阅读页面"
addSubview(contentAccessibilityView)
addSubview(selectionLoupe)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.delegate = self
tap.require(toFail: zoomView.doubleTapZoomGestureRecognizer)
tap.require(toFail: textLayer.longPressGestureRecognizer)
tap.require(toFail: textLayer.selectionHandlePanGestureRecognizer)
tap.cancelsTouchesInView = false
addGestureRecognizer(tap)
updateAccessibilityViewport()
} }
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
public override func layoutSubviews() {
super.layoutSubviews()
zoomView.layoutIfNeeded()
textLayer.frame = zoomView.contentView.bounds
}
// MARK: -
@objc private func handleTap(_ gesture: UITapGestureRecognizer) { @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: textLayer) if textLayer.selectedSelection != nil {
textLayer.clearSelection()
if let highlight = highlightAt(point) { return
}
let textLayerPoint = gesture.location(in: textLayer)
if let annotation = textLayer.note(at: textLayerPoint) {
delegate?.pageView(self, didOpenAnnotation: annotation)
return
}
if let highlight = highlightAt(textLayerPoint) {
showExistingHighlightMenu(for: highlight, at: gesture.location(in: self)) showExistingHighlightMenu(for: highlight, at: gesture.location(in: self))
return return
} }
tappedHighlight = nil
readerContentTapHandler?(gesture.location(in: self))
} }
private func highlightAt(_ point: CGPoint) -> RDPDFReaderAnnotation? { private func highlightAt(_ point: CGPoint) -> RDPDFReaderAnnotation? {
let currentPageAnnotations = textLayer.annotations.filter { $0.pageIndex == textLayer.pageIndex } guard bounds.width > 0, bounds.height > 0 else { return nil }
for annotation in currentPageAnnotations { let normalizedPoint = CGPoint(
for rect in annotation.normalizedRects { x: point.x / textLayer.bounds.width,
if rect.contains(point) { y: point.y / textLayer.bounds.height
return annotation )
} for annotation in textLayer.annotations where annotation.pageIndex == textLayer.pageIndex {
for rect in annotation.normalizedRects where rect.contains(normalizedPoint) {
return annotation
} }
} }
return nil return nil
} }
// MARK: - EPUB /线
public override var canBecomeFirstResponder: Bool { true }
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if tappedHighlight != nil {
return action == #selector(rd_copyHighlight(_:))
|| action == #selector(rd_deleteHighlight(_:))
|| action == #selector(rd_annotateHighlight(_:))
|| action == #selector(rd_deleteAnnotation(_:))
}
guard let selection = textLayer.selectedSelection else { return false }
switch action {
case #selector(rd_copy(_:)):
return selection.canCopyText
case #selector(rd_highlight(_:)), #selector(rd_annotate(_:)):
return true
default:
return false
}
}
private func handleSelectionChange(_ selection: RDPDFReaderImageTextSelection?) {
if selection == nil {
hideMenu()
}
readerSelectionStateChangedHandler?(selection != nil)
delegate?.pageView(self, didChangeSelection: selection)
}
private func handleSelectionInteractionStateChange(_ state: RDPDFReaderSelectionInteractionState) {
switch state {
case .selectionActive:
showSelectionMenu()
case .idle, .selecting, .adjustingHandle:
hideMenu()
}
}
private func showSelectionMenu() {
guard textLayer.selectedSelection != nil,
let anchorRect = textLayer.menuAnchorRect() else {
return
}
tappedHighlight = nil
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "复制", action: #selector(rd_copy(_:))),
UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "注释", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(convert(anchorRect, from: textLayer), in: self)
menuController.setMenuVisible(true, animated: true)
}
private func showExistingHighlightMenu(for annotation: RDPDFReaderAnnotation, at point: CGPoint) { private func showExistingHighlightMenu(for annotation: RDPDFReaderAnnotation, at point: CGPoint) {
tappedHighlight = annotation tappedHighlight = annotation
becomeFirstResponder() becomeFirstResponder()
var menuItems: [UIMenuItem] = [ var menuItems = [UIMenuItem(title: "复制", action: #selector(rd_copyHighlight(_:)))]
UIMenuItem(title: "复制", action: #selector(rd_copy_highlight(_:)))
]
if annotation.note?.isEmpty == false { if annotation.note?.isEmpty == false {
menuItems.append(UIMenuItem(title: "删除注释", action: #selector(rd_delete_annotation(_:)))) menuItems.append(UIMenuItem(title: "删除注释", action: #selector(rd_deleteAnnotation(_:))))
} else { } else {
menuItems.append(UIMenuItem(title: "注释", action: #selector(rd_annotate_highlight(_:)))) menuItems.append(UIMenuItem(title: "注释", action: #selector(rd_annotateHighlight(_:))))
} }
menuItems.append(UIMenuItem(title: "删除划线", action: #selector(rd_deleteHighlight(_:))))
menuItems.append(UIMenuItem(title: "删除划线", action: #selector(rd_delete_highlight(_:))))
let menuController = UIMenuController.shared let menuController = UIMenuController.shared
menuController.menuItems = menuItems menuController.menuItems = menuItems
@ -80,39 +269,85 @@ open class RDPDFReaderPageView: UIView, UIGestureRecognizerDelegate {
menuController.setMenuVisible(true, animated: true) menuController.setMenuVisible(true, animated: true)
} }
override open var canBecomeFirstResponder: Bool { true } private func hideMenu() {
UIMenuController.shared.setMenuVisible(false, animated: true)
}
override open func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { private func updateSelectionLoupe(_ focusPoint: CGPoint?) {
if tappedHighlight != nil { guard let focusPoint else {
return action == #selector(rd_copy_highlight(_:)) selectionLoupe.dismiss()
|| action == #selector(rd_delete_highlight(_:)) return
|| action == #selector(rd_annotate_highlight(_:))
|| action == #selector(rd_delete_annotation(_:))
} }
return false selectionLoupe.present(
sourceView: zoomView.contentView,
focusPoint: textLayer.convert(focusPoint, to: zoomView.contentView),
hostBounds: bounds,
targetPoint: convert(focusPoint, from: textLayer)
)
} }
@objc private func rd_copy_highlight(_ sender: Any?) { // MARK: -
guard let highlight = tappedHighlight else { return }
@objc private func rd_copy(_ sender: Any?) {
guard let selection = textLayer.selectedSelection,
selection.canCopyText,
let text = selection.text?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else { return }
UIPasteboard.general.string = text
delegate?.pageView(self, didCopyText: text)
textLayer.clearSelection()
}
@objc private func rd_highlight(_ sender: Any?) {
guard let selection = textLayer.selectedSelection else { return }
delegate?.pageView(self, didRequestHighlight: selection, color: Self.defaultHighlightColor)
textLayer.clearSelection()
}
@objc private func rd_annotate(_ sender: Any?) {
guard let selection = textLayer.selectedSelection else { return }
delegate?.pageView(self, didRequestAnnotation: selection)
textLayer.clearSelection()
}
// MARK: - 线
@objc private func rd_copyHighlight(_ sender: Any?) {
guard let highlight = consumeTappedHighlight() else { return }
if let text = highlight.selectedText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
UIPasteboard.general.string = text
delegate?.pageView(self, didCopyText: text)
}
delegate?.pageView(self, didRequestHighlightMenuAction: .copy, highlight: highlight) delegate?.pageView(self, didRequestHighlightMenuAction: .copy, highlight: highlight)
tappedHighlight = nil
} }
@objc private func rd_annotate_highlight(_ sender: Any?) { @objc private func rd_annotateHighlight(_ sender: Any?) {
guard let highlight = tappedHighlight else { return } guard let highlight = consumeTappedHighlight() else { return }
delegate?.pageView(self, didRequestHighlightMenuAction: .annotate, highlight: highlight) delegate?.pageView(self, didRequestHighlightMenuAction: .annotate, highlight: highlight)
tappedHighlight = nil
} }
@objc private func rd_delete_annotation(_ sender: Any?) { @objc private func rd_deleteAnnotation(_ sender: Any?) {
guard let highlight = tappedHighlight else { return } guard let highlight = consumeTappedHighlight() else { return }
delegate?.pageView(self, didRequestHighlightMenuAction: .deleteAnnotation, highlight: highlight) delegate?.pageView(self, didRequestHighlightMenuAction: .deleteAnnotation, highlight: highlight)
tappedHighlight = nil
} }
@objc private func rd_delete_highlight(_ sender: Any?) { @objc private func rd_deleteHighlight(_ sender: Any?) {
guard let highlight = tappedHighlight else { return } guard let highlight = consumeTappedHighlight() else { return }
delegate?.pageView(self, didRequestHighlightMenuAction: .deleteUnderline, highlight: highlight) delegate?.pageView(self, didRequestHighlightMenuAction: .deleteUnderline, highlight: highlight)
tappedHighlight = nil }
private func consumeTappedHighlight() -> RDPDFReaderAnnotation? {
defer { tappedHighlight = nil }
return tappedHighlight
}
private func updateAccessibilityViewport() {
let offset = zoomView.contentOffset
contentAccessibilityView.accessibilityValue = String(
format: "zoom=%.2f offset=%.0f,%.0f",
zoomView.zoomScale,
offset.x,
offset.y
)
} }
} }