add tap-on-highlight menu with copy/annotate/delete actions, matching EPUB behavior

- Add RDPDFReaderAnnotationMenuAction and RDPDFReaderExistingHighlightMenuAction enums to PDF contracts
- Enable highlight overlay view to detect tap gestures and report tapped highlights
- Implement existing highlight menu in Demo page: copy text, add/edit annotation, delete annotation, delete underline
- Handle menu actions in PDFDemoReaderViewController to persist changes
- Ensure highlight tap detection doesn't interfere with text selection

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-07-15 10:06:02 +09:00
parent fed34da246
commit d56e99a83f
3 changed files with 1808 additions and 2 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,297 @@
import UIKit
/// SDK
public struct RDPDFReaderBookDescriptor: Equatable {
public let identifier: String
public let title: String
public let totalPages: Int
public init(identifier: String, title: String, totalPages: Int) {
self.identifier = identifier
self.title = title
self.totalPages = totalPages
}
}
/// 宿 PDF SDK
/// SDK PDF
public struct RDPDFReaderPageDescriptor {
public let index: Int
public let image: UIImage?
/// 宿使 0...1
/// `nil` 宿SDK 使 OCR
public let textRuns: [RDPDFReaderTextRun]?
public init(index: Int, image: UIImage?) {
self.index = index
self.image = image
textRuns = nil
}
public init(index: Int, image: UIImage?, textRuns: [RDPDFReaderTextRun]?) {
self.index = index
self.image = image
self.textRuns = textRuns
}
}
/// run 便宿
/// OCR
///
/// x/y/width/height 0...1
public struct RDPDFReaderTextRun: Equatable, Codable {
public let text: String
public let normalizedRects: [CGRect]
/// SDK
public let readingOrder: Int
/// `text` `Character`
/// `nil` SDK
///
public let characterRects: [CGRect]?
public init(text: String, normalizedRects: [CGRect], readingOrder: Int, characterRects: [CGRect]? = nil) {
self.text = text
self.normalizedRects = normalizedRects
self.readingOrder = readingOrder
self.characterRects = characterRects
}
/// /便
public init(text: String, normalizedRect: CGRect, readingOrder: Int, characterRects: [CGRect]? = nil) {
self.init(text: text, normalizedRects: [normalizedRect], readingOrder: readingOrder, characterRects: characterRects)
}
/// 便
public var normalizedRect: CGRect {
normalizedRects.reduce(into: CGRect.null) { result, rect in
result = result.union(rect)
}
}
}
/// UI 宿OCR
public enum RDPDFReaderAnnotationSource: String, Codable, Equatable {
/// 宿 PDF
case text
/// SDK OCR
case ocr
///
case region
///
///
public init(from decoder: Decoder) throws {
let rawValue = try decoder.singleValueContainer().decode(String.self)
self = Self(rawValue: rawValue) ?? .region
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
}
/// PDF `note == nil` `note`
/// `RDPDFReaderTextRun` 使 0...1
public struct RDPDFReaderAnnotation: Equatable, Codable {
public let id: String
public var pageIndex: Int
public var selectedText: String?
public var normalizedRects: [CGRect]
public var color: String
public var note: String?
public var source: RDPDFReaderAnnotationSource
public var createdAt: Date
public var updatedAt: Date
private enum CodingKeys: String, CodingKey {
case id
case pageIndex
case selectedText
case normalizedRects
case color
case note
case source
case createdAt
case updatedAt
}
public init(
id: String = UUID().uuidString,
pageIndex: Int,
selectedText: String? = nil,
normalizedRects: [CGRect],
color: String = "#F8E16C",
note: String? = nil,
source: RDPDFReaderAnnotationSource = .region,
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.pageIndex = pageIndex
self.selectedText = selectedText
self.normalizedRects = normalizedRects
self.color = color
self.note = note
self.source = source
self.createdAt = createdAt
self.updatedAt = updatedAt
}
///
///
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
pageIndex = try container.decodeIfPresent(Int.self, forKey: .pageIndex) ?? 0
selectedText = try container.decodeIfPresent(String.self, forKey: .selectedText)
normalizedRects = try container.decodeIfPresent([CGRect].self, forKey: .normalizedRects) ?? []
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
note = try container.decodeIfPresent(String.self, forKey: .note)
source = try container.decodeIfPresent(RDPDFReaderAnnotationSource.self, forKey: .source) ?? .region
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(pageIndex, forKey: .pageIndex)
try container.encodeIfPresent(selectedText, forKey: .selectedText)
try container.encode(normalizedRects, forKey: .normalizedRects)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(note, forKey: .note)
try container.encode(source, forKey: .source)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(updatedAt, forKey: .updatedAt)
}
/// 便
public init(
id: String = UUID().uuidString,
pageIndex: Int,
selectedText: String? = nil,
normalizedRect: CGRect,
color: String = "#F8E16C",
note: String? = nil,
source: RDPDFReaderAnnotationSource = .region,
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.init(
id: id,
pageIndex: pageIndex,
selectedText: selectedText,
normalizedRects: [normalizedRect],
color: color,
note: note,
source: source,
createdAt: createdAt,
updatedAt: updatedAt
)
}
///
public var normalizedRect: CGRect {
normalizedRects.reduce(into: CGRect.null) { result, rect in
result = result.union(rect)
}
}
}
public struct RDPDFReaderOutlineItem: Equatable {
public let title: String
public let pageIndex: Int
public let level: Int
public init(title: String, pageIndex: Int, level: Int = 0) {
self.title = title
self.pageIndex = pageIndex
self.level = level
}
}
public struct RDPDFReaderBookmark: Equatable {
public let pageIndex: Int
public let title: String?
public init(pageIndex: Int, title: String? = nil) {
self.pageIndex = pageIndex
self.title = title
}
}
/// PDF 0...1
public struct RDPDFReaderHighlight: Equatable, Codable {
public let id: Int
public let pageIndex: Int
public let selectedText: String
public let color: String
public let normalizedRect: CGRect
public init(id: Int, pageIndex: Int, selectedText: String, color: String, normalizedRect: CGRect) {
self.id = id
self.pageIndex = pageIndex
self.selectedText = selectedText
self.color = color
self.normalizedRect = normalizedRect
}
}
/// SDK
public protocol RDPDFReaderPageProvider: AnyObject {
func readerBookDescriptor() -> RDPDFReaderBookDescriptor
func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void)
func readerThumbnail(at index: Int, targetSize: CGSize, completion: @escaping (UIImage?) -> Void)
}
/// 宿SDK
public protocol RDPDFReaderPersistence: AnyObject {
func restoreReadingPage(for bookIdentifier: String) -> Int?
func saveReadingPage(_ pageIndex: Int, for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDPDFReaderBookmark]
func setBookmark(_ isBookmarked: Bool, pageIndex: Int, for bookIdentifier: String)
}
/// 宿SDK
public enum RDPDFReaderHostAction: Equatable {
case back
case openLink(identifier: String)
case playMedia(identifier: String)
case requestPurchase
}
/// SDK 使宿
public enum RDPDFReaderDisplayMode: Int {
case pageCurl
case horizontalScroll
case verticalScroll
}
/// UI 宿
public enum RDPDFReaderDrawingTool: Int, Codable {
// PDF JSON
case pen = 1
case highlighter = 2
case eraser = 3
}
///
public enum RDPDFReaderAnnotationMenuAction {
case copy
case highlight
case annotate
}
/// 线线
public enum RDPDFReaderExistingHighlightMenuAction {
case copy
case createUnderline
case deleteUnderline
case annotate
case deleteAnnotation
}
public protocol RDPDFReaderHostActionHandling: AnyObject {
func readerHandle(action: RDPDFReaderHostAction)
}

View File

@ -1,18 +1,32 @@
import UIKit
/// SDK
/// SDK
public final class RDPDFReaderHighlightOverlayView: UIView {
public var highlights: [RDPDFReaderHighlight] = [] { didSet { setNeedsDisplay() } }
public var onHighlightTapped: ((RDPDFReaderHighlight, CGPoint) -> Void)?
public override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false
backgroundColor = .clear
isUserInteractionEnabled = false
isUserInteractionEnabled = true
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))))
}
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) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.clear(rect)