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:
parent
fed34da246
commit
d56e99a83f
1495
ReadViewDemo/ReadViewDemo/PDFDemoReaderViewController.swift
Normal file
1495
ReadViewDemo/ReadViewDemo/PDFDemoReaderViewController.swift
Normal file
File diff suppressed because it is too large
Load Diff
297
Sources/RDPDFReaderView/Sources/RDPDFReaderContracts.swift
Normal file
297
Sources/RDPDFReaderView/Sources/RDPDFReaderContracts.swift
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user