修复 PDF 阅读交互稳定性并统一项目技能管理

PDF 阅读器在慢速双指缩放、竖向滚动页高异步更新、页面请求超时和绘画橡皮擦等场景中,存在视口跳动、迟到结果被错误丢弃、缓存边界不明确及笔迹显示与持久化不同步的问题;同一本书的标注读取和并发写入也会产生不必要的磁盘访问或覆盖风险。\n\n调整缩放 inset 计算和竖向阅读锚点恢复,避免缩放变换被重复计入、真实页高到达时改变当前阅读位置。页面请求改为带 token 的超时重试机制,可在缓存窗口内接收有效迟到结果并提供失败重试入口;页面大图、OCR、笔迹和描述缓存统一按当前页前后两页收敛,PDF 标识改为完整内容 SHA-256,避免同名或相近文件复用错误阅读状态。\n\n绘画会话内按路径实时重绘,退出会话后使用按图层派生的位图缓存,确保橡皮擦即时作用于已提交笔迹;同时为笔迹持久化增加版本控制、为标注读写增加同步保护和内存快照,降低复用与并发场景下的状态错乱。\n\n将项目技能统一迁入 .agents/skills,并以 .claude/skills 相对软链接供 Claude Code 读取;新增详细 Git 提交技能,自动审查改动、生成中文提交说明并约束安全推送。

验证:所有项目技能通过 quick_validate;执行 xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build,构建成功。
This commit is contained in:
shenlei
2026-07-24 14:40:02 +09:00
parent 68d9363f0a
commit 95cead5863
30 changed files with 995 additions and 907 deletions
@@ -1,5 +1,6 @@
import UIKit
import PDFKit
import CryptoKit
public enum RDPDFKitPageProviderError: LocalizedError {
case cannotOpen(URL)
@@ -28,6 +29,8 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
private let descriptor: RDPDFReaderBookDescriptor
private let renderQueue = DispatchQueue(label: "com.readviewsdk.pdfkit.render", qos: .userInitiated)
private let pageCache = NSCache<NSNumber, UIImage>()
/// `renderQueue` 访 NSCache
private var cachedPageIndexes = Set<Int>()
private let thumbnailCache = NSCache<NSString, UIImage>()
private let maximumPagePixelDimension: CGFloat
private let screenScale: CGFloat
@@ -85,7 +88,7 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
profile: "cropbox-normalized-1"
)
: nil
pageCache.countLimit = 6
pageCache.countLimit = 5
pageCache.totalCostLimit = 96 * 1024 * 1024
thumbnailCache.countLimit = 80
thumbnailCache.totalCostLimit = 24 * 1024 * 1024
@@ -116,6 +119,7 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
let image = autoreleasepool { self.renderPage(page) }
if let image {
self.pageCache.setObject(image, forKey: NSNumber(value: index), cost: image.rdEstimatedMemoryCost)
self.cachedPageIndexes.insert(index)
}
let runs = self.cachedTextRuns(for: index, page: page)
DispatchQueue.main.async {
@@ -159,9 +163,26 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
}
public func removeCachedImages() {
pageCache.removeAllObjects()
thumbnailCache.removeAllObjects()
renderQueue.async { [weak self] in self?.textRunsCache.removeAll(keepingCapacity: false) }
renderQueue.async { [weak self] in
self?.pageCache.removeAllObjects()
self?.thumbnailCache.removeAllObjects()
self?.cachedPageIndexes.removeAll(keepingCapacity: false)
self?.textRunsCache.removeAll(keepingCapacity: false)
}
}
/// 访
/// PDF OCR/PDFKit
public func trimPageImages(around pageIndex: Int, radius: Int) {
let safeRadius = max(0, radius)
renderQueue.async { [weak self] in
guard let self else { return }
let stalePages = self.cachedPageIndexes.filter { abs($0 - pageIndex) > safeRadius }
stalePages.forEach {
self.pageCache.removeObject(forKey: NSNumber(value: $0))
self.cachedPageIndexes.remove($0)
}
}
}
/// PDF
@@ -209,21 +230,25 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
return runs
}
/// iOS
///
/// identifier key
/// 使 PDF
///
private static func defaultBookIdentifier(for url: URL) -> String {
let fileURL = url.standardizedFileURL
let fileSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize
var signature = "\(fileURL.lastPathComponent)|\(fileSize.map(String.init) ?? "")"
if let handle = try? FileHandle(forReadingFrom: fileURL) {
let head = handle.readData(ofLength: 64 * 1024)
handle.closeFile()
if head.isEmpty == false {
signature += "|\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: head))"
}
guard let handle = try? FileHandle(forReadingFrom: fileURL) else {
let fallback = "\(fileURL.lastPathComponent)|\(fileSize.map(String.init) ?? "")"
return "pdf.\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: fallback))"
}
return "pdf.\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: signature))"
defer { handle.closeFile() }
var hasher = SHA256()
while true {
let chunk = handle.readData(ofLength: 1024 * 1024)
guard chunk.isEmpty == false else { break }
hasher.update(data: chunk)
}
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
return "pdf.sha256.\(digest)"
}
private func outlineItemsLocked() -> [RDPDFReaderOutlineItem] {
@@ -143,6 +143,12 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
///
///
var pendingWidthFitJumpPage: Int?
/// cell
///
private struct VerticalReadingAnchor {
let page: Int
let offsetFromPageTop: CGFloat
}
private var reusableTypes: [String: UIView.Type] = [:]
public override init(frame: CGRect) {
@@ -314,29 +320,52 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
/// 宿
public func invalidateWidthFitLayoutIfNeeded(updatedPage: Int? = nil) {
guard usesWidthFitVerticalScroll else { return }
let readingAnchor = verticalReadingAnchor()
collectionView.collectionViewLayout.invalidateLayout()
//
//
guard let updatedPage, updatedPage == pendingWidthFitJumpPage else { return }
DispatchQueue.main.async { [weak self] in
guard let self,
self.usesWidthFitVerticalScroll,
self.currentPage == updatedPage,
self.pendingWidthFitJumpPage == updatedPage else { return }
//
guard self.collectionView.isTracking == false else {
self.pendingWidthFitJumpPage = nil
guard let self, self.usesWidthFitVerticalScroll else { return }
//
guard !self.collectionView.isTracking,
!self.collectionView.isDragging,
!self.collectionView.isDecelerating else {
//
//
if updatedPage == self.pendingWidthFitJumpPage {
self.pendingWidthFitJumpPage = nil
}
return
}
self.collectionView.layoutIfNeeded()
self.collectionView.setContentOffset(
CGPoint(x: 0, y: self.clampedVerticalOffset(self.anchorY(forItem: updatedPage))),
animated: false
)
self.pendingWidthFitJumpPage = nil
if let updatedPage,
updatedPage == self.pendingWidthFitJumpPage,
self.currentPage == updatedPage {
self.collectionView.setContentOffset(
CGPoint(x: 0, y: self.clampedVerticalOffset(self.anchorY(forItem: updatedPage))),
animated: false
)
self.pendingWidthFitJumpPage = nil
} else if let readingAnchor {
let targetY = self.anchorY(forItem: readingAnchor.page) + readingAnchor.offsetFromPageTop
self.collectionView.setContentOffset(
CGPoint(x: 0, y: self.clampedVerticalOffset(targetY)),
animated: false
)
}
}
}
private func verticalReadingAnchor() -> VerticalReadingAnchor? {
guard currentDisplayType == .verticalScroll,
collectionView.contentSize.height > 0 else { return nil }
let visibleY = max(0, collectionView.contentOffset.y)
let point = CGPoint(x: collectionView.bounds.midX, y: visibleY + 1)
guard let indexPath = collectionView.indexPathForItem(at: point) else { return nil }
return VerticalReadingAnchor(
page: indexPath.item,
offsetFromPageTop: visibleY - anchorY(forItem: indexPath.item)
)
}
public func setPagingEnabled(_ enabled: Bool) { isPagingEnabled = enabled; updatePagingState() }
/// /
@@ -708,7 +737,10 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
}
private func updateInsets() {
let size = canvasView.frame.size
// UIScrollView canvasView transform`frame`
// zoomScale pinch inset
// UIKit contentOffset
let size = canvasView.bounds.size
scrollView.contentInset = UIEdgeInsets(
top: max(0, (bounds.height - size.height * scrollView.zoomScale) / 2),
left: max(0, (bounds.width - size.width * scrollView.zoomScale) / 2),
@@ -94,19 +94,23 @@ public struct RDPDFReaderDrawingDocument: Codable {
public let pageNo: Int
public let paths: [RDPDFReaderDrawingPath]
public let layers: [RDPDFReaderDrawingLayer]
/// 0
public let revision: Int
public init(pageNo: Int, paths: [RDPDFReaderDrawingPath], layers: [RDPDFReaderDrawingLayer] = []) {
public init(pageNo: Int, paths: [RDPDFReaderDrawingPath], layers: [RDPDFReaderDrawingLayer] = [], revision: Int = 0) {
self.pageNo = pageNo
self.paths = paths
self.layers = layers
self.revision = max(0, revision)
}
private enum CodingKeys: String, CodingKey { case pageNo, paths, layers }
private enum CodingKeys: String, CodingKey { case pageNo, paths, layers, revision }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
pageNo = try container.decode(Int.self, forKey: .pageNo)
paths = try container.decode([RDPDFReaderDrawingPath].self, forKey: .paths)
layers = try container.decodeIfPresent([RDPDFReaderDrawingLayer].self, forKey: .layers) ?? []
revision = max(0, try container.decodeIfPresent(Int.self, forKey: .revision) ?? 0)
}
}
@@ -124,6 +128,14 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
public var strokeColor: UIColor = .black
public var lineWidth: CGFloat = 2
public var currentPage = 0
/// 使退
public var usesPathRendering = false {
didSet {
guard oldValue != usesPathRendering else { return }
if !usesPathRendering { finishCurrentStroke() }
setNeedsDisplay()
}
}
public var pathsChangedHandler: (([RDPDFReaderDrawingPath]) -> Void)?
///
public var strokeEventHandler: ((CGPoint, RDPDFReaderDrawingStrokePhase) -> Void)?
@@ -136,6 +148,15 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
private var undoStack: [DrawingAction] = []
private var redoStack: [DrawingAction] = []
private let maxUndoCount = 50
/// 退
private let committedLayerImages: NSCache<NSUUID, UIImage> = {
let cache = NSCache<NSUUID, UIImage>()
cache.countLimit = 4
cache.totalCostLimit = 48 * 1024 * 1024
return cache
}()
private var documentRevision = 0
private var cachedRenderingSize = CGSize.zero
public override init(frame: CGRect) {
super.init(frame: frame)
@@ -152,6 +173,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
public func load(document: RDPDFReaderDrawingDocument) {
paths = document.paths
layers = document.layers
documentRevision = document.revision
if layers.isEmpty {
let defaultLayer = RDPDFReaderDrawingLayer(name: "图层 1")
layers = [defaultLayer]
@@ -173,9 +195,18 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
currentPath = nil
undoStack.removeAll()
redoStack.removeAll()
invalidateCommittedLayerImages()
setNeedsDisplay()
}
public override func layoutSubviews() {
super.layoutSubviews()
// referencePageSize
guard cachedRenderingSize != bounds.size else { return }
cachedRenderingSize = bounds.size
invalidateCommittedLayerImages()
}
/// 1
public func loadPaths(_ paths: [RDPDFReaderDrawingPath]) { load(document: .init(pageNo: currentPage, paths: paths)) }
@@ -186,7 +217,11 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
commitCurrentStroke()
activeDrawingTouch = nil
}
public func drawingDocument() -> RDPDFReaderDrawingDocument { .init(pageNo: currentPage, paths: paths, layers: layers) }
public func drawingDocument() -> RDPDFReaderDrawingDocument {
.init(pageNo: currentPage, paths: paths, layers: layers, revision: documentRevision)
}
public func acknowledgePersistedRevision(_ revision: Int) { documentRevision = max(documentRevision, revision) }
public func releaseRenderingCache() { invalidateCommittedLayerImages() }
public func drawingLayers() -> [RDPDFReaderDrawingLayer] { layers }
public func selectedDrawingLayerID() -> UUID? { activeLayerID }
@@ -194,7 +229,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
let layer = RDPDFReaderDrawingLayer(name: "图层 \(layers.count + 1)")
layers.insert(layer, at: 0)
activeLayerID = layer.id
didChangePaths()
didChangePaths(affectedLayerIDs: [layer.id])
return layer
}
@@ -208,7 +243,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
guard let index = layers.firstIndex(where: { $0.id == id }) else { return }
layers[index].isVisible = isVisible
if activeLayerID == id, !isVisible { activeLayerID = layers.first(where: \.isVisible)?.id }
didChangePaths()
didChangePaths(affectedLayerIDs: [id])
}
public func deleteLayer(id: UUID) {
@@ -216,7 +251,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
paths.removeAll { $0.layerID == id }
layers.remove(at: index)
if activeLayerID == id { activeLayerID = layers.first(where: \.isVisible)?.id ?? layers.first?.id }
didChangePaths()
didChangePaths(affectedLayerIDs: [id])
}
public func clearAll() {
@@ -251,12 +286,22 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
public override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
if usesPathRendering {
drawUsingPaths(in: context)
return
}
drawUsingCommittedImages()
}
private func drawUsingPaths(in context: CGContext) {
// 穿
layers.filter(\.isVisible).forEach { layer in
context.saveGState()
context.beginTransparencyLayer(auxiliaryInfo: nil)
paths.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: context) }
if let currentPath, currentPath.layerID == layer.id { draw(path: currentPath, in: context) }
paths.lazy.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: context) }
if let currentPath, currentPath.layerID == layer.id {
draw(path: currentPath, in: context)
}
context.endTransparencyLayer()
context.restoreGState()
}
@@ -265,6 +310,16 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
if let currentPath, currentPath.layerID == nil { draw(path: currentPath, in: context) }
}
private func drawUsingCommittedImages() {
layers.filter(\.isVisible).forEach { layer in
committedLayerImage(for: layer)?.draw(in: bounds)
}
// load
if paths.contains(where: { $0.layerID == nil }), let context = UIGraphicsGetCurrentContext() {
paths.filter { $0.layerID == nil }.forEach { draw(path: $0, in: context) }
}
}
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard activeTouchCount(in: event) == 1, let touch = touches.first else { cancelCurrentStroke(); return }
activeDrawingTouch = touch
@@ -317,7 +372,12 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
case .clear(let saved):
if isUndo { paths = saved } else { paths.removeAll() }
}
didChangePaths()
let affectedLayers: Set<UUID>
switch action {
case .add(let path): affectedLayers = Set([path.layerID].compactMap { $0 })
case .remove(let paths), .clear(let paths): affectedLayers = Set(paths.compactMap(\.layerID))
}
didChangePaths(affectedLayerIDs: affectedLayers.isEmpty ? nil : affectedLayers)
}
private func appendPoints(from touch: UITouch, event: UIEvent?) {
@@ -336,10 +396,45 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
paths.append(path)
record(.add(path))
currentPath = nil
didChangePaths()
if let layerID = path.layerID {
didChangePaths(affectedLayerIDs: [layerID])
} else {
didChangePaths()
}
}
private func cancelCurrentStroke() { currentPath = nil; activeDrawingTouch = nil; setNeedsDisplay() }
private func didChangePaths() { setNeedsDisplay(); pathsChangedHandler?(paths) }
private func didChangePaths(affectedLayerIDs: Set<UUID>? = nil) {
if let affectedLayerIDs {
affectedLayerIDs.forEach { committedLayerImages.removeObject(forKey: $0 as NSUUID) }
} else {
invalidateCommittedLayerImages()
}
setNeedsDisplay()
pathsChangedHandler?(paths)
}
private func invalidateCommittedLayerImages() { committedLayerImages.removeAllObjects() }
private func committedLayerImage(for layer: RDPDFReaderDrawingLayer) -> UIImage? {
let key = layer.id as NSUUID
if let image = committedLayerImages.object(forKey: key) { return image }
guard bounds.width > 0, bounds.height > 0 else { return nil }
let format = UIGraphicsImageRendererFormat.default()
format.opaque = false
// 250 NSCache iPad
let basePixels = max(1, bounds.width * bounds.height)
let maximumScale = sqrt(2_500_000 / basePixels)
format.scale = max(1, min(contentScaleFactor, maximumScale))
let image = UIGraphicsImageRenderer(size: bounds.size, format: format).image { rendererContext in
let layerContext = rendererContext.cgContext
layerContext.beginTransparencyLayer(auxiliaryInfo: nil)
paths.lazy.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: layerContext) }
layerContext.endTransparencyLayer()
}
let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0
committedLayerImages.setObject(image, forKey: key, cost: cost)
return image
}
private func draw(path: RDPDFReaderDrawingPath, in context: CGContext) {
guard !path.points.isEmpty else { return }
@@ -21,7 +21,12 @@ public final class RDPDFReaderImageTextRecognizer {
qos: .userInitiated
)
private let requestLock = NSLock()
private var pendingRequests: [UUID: DispatchWorkItem] = [:]
private struct PendingRequest {
let workItem: DispatchWorkItem
let completion: Completion
}
private var pendingRequests: [UUID: PendingRequest] = [:]
/// `perform` `DispatchWorkItem.cancel()`
/// VNRequest `cancel()`
private var activeVisionRequests: [UUID: VNRecognizeTextRequest] = [:]
@@ -57,7 +62,7 @@ public final class RDPDFReaderImageTextRecognizer {
let request = VNRecognizeTextRequest { request, _ in
let observations = request.results as? [VNRecognizedTextObservation] ?? []
let runs = Self.makeTextRuns(from: observations)
guard self.removeRequest(requestID) else { return }
guard let completion = self.takeCompletion(for: requestID) else { return }
self.deliver(runs, to: completion)
}
request.recognitionLevel = configuration.recognitionLevel
@@ -73,27 +78,30 @@ public final class RDPDFReaderImageTextRecognizer {
}
self.unregisterActiveVisionRequest(requestID)
if performFailed {
guard self.removeRequest(requestID) else { return }
guard let completion = self.takeCompletion(for: requestID) else { return }
self.deliver([], to: completion)
}
}
requestLock.lock()
pendingRequests[requestID] = workItem
pendingRequests[requestID] = PendingRequest(workItem: workItem, completion: completion)
requestLock.unlock()
processingQueue.async(execute: workItem)
return requestID
}
///
/// OCR
///
/// Swift Concurrency
public func cancelAllRequests() {
requestLock.lock()
let queued = pendingRequests.values
let inFlight = activeVisionRequests.values
let cancelled = Array(pendingRequests.values)
let inFlight = Array(activeVisionRequests.values)
pendingRequests.removeAll()
activeVisionRequests.removeAll()
requestLock.unlock()
queued.forEach { $0.cancel() }
cancelled.forEach {
$0.workItem.cancel()
deliver([], to: $0.completion)
}
//
inFlight.forEach { $0.cancel() }
}
@@ -211,7 +219,7 @@ public final class RDPDFReaderImageTextRecognizer {
private func registerActiveVisionRequest(_ request: VNRecognizeTextRequest, for requestID: UUID) -> Bool {
requestLock.lock()
defer { requestLock.unlock() }
guard pendingRequests[requestID]?.isCancelled == false else { return false }
guard pendingRequests[requestID]?.workItem.isCancelled == false else { return false }
activeVisionRequests[requestID] = request
return true
}
@@ -222,13 +230,11 @@ public final class RDPDFReaderImageTextRecognizer {
requestLock.unlock()
}
///
@discardableResult
private func removeRequest(_ requestID: UUID) -> Bool {
/// Vision
private func takeCompletion(for requestID: UUID) -> Completion? {
requestLock.lock()
defer { requestLock.unlock() }
guard let request = pendingRequests.removeValue(forKey: requestID), !request.isCancelled else { return false }
return true
return pendingRequests.removeValue(forKey: requestID)?.completion
}
}
@@ -60,12 +60,19 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
private let drawingCanvas = RDPDFReaderDrawingCanvasView()
private let contentAccessibilityView = UIView()
private let selectionLoupe = RDPDFReaderSelectionLoupeView()
private let pageLoadFailureButton = UIButton(type: .system)
private var tappedHighlight: RDPDFReaderAnnotation?
private var configuredDrawingPageIndex: Int?
private var pageLoadRetryHandler: (() -> Void)?
/// PDF
///
public var isDrawingSessionActive = false {
didSet {
guard oldValue != isDrawingSessionActive else { return }
// 退
if !isDrawingSessionActive { drawingCanvas.finishCurrentStroke() }
drawingCanvas.usesPathRendering = isDrawingSessionActive
textLayer.isSelectionEnabled = !isDrawingSessionActive
if isDrawingSessionActive { textLayer.clearSelection() }
}
@@ -148,8 +155,14 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
document: RDPDFReaderDrawingDocument,
documentChanged: @escaping (RDPDFReaderDrawingDocument) -> Void
) {
drawingCanvas.currentPage = pageIndex
drawingCanvas.load(document: document)
// OCR load
// currentPath
if configuredDrawingPageIndex != pageIndex {
drawingCanvas.finishCurrentStroke()
drawingCanvas.currentPage = pageIndex
drawingCanvas.load(document: document)
configuredDrawingPageIndex = pageIndex
}
drawingCanvas.pathsChangedHandler = { [weak drawingCanvas] _ in
guard let drawingCanvas else { return }
documentChanged(drawingCanvas.drawingDocument())
@@ -175,6 +188,14 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
public func selectDrawingLayer(id: UUID) { drawingCanvas.selectLayer(id: id) }
public func setDrawingLayerVisibility(id: UUID, isVisible: Bool) { drawingCanvas.setLayerVisibility(id: id, isVisible: isVisible) }
public func deleteDrawingLayer(id: UUID) { drawingCanvas.deleteLayer(id: id) }
func acknowledgeDrawingRevision(_ revision: Int) { drawingCanvas.acknowledgePersistedRevision(revision) }
func releaseDrawingRenderingCache() { drawingCanvas.releaseRenderingCache() }
func setPageLoadFailed(_ failed: Bool, retryHandler: (() -> Void)? = nil) {
pageLoadRetryHandler = retryHandler
pageLoadFailureButton.isHidden = !failed
pageLoadFailureButton.isEnabled = failed
}
func containsDrawingPoint(_ point: CGPoint, from sourceView: UIView) -> Bool {
drawingCanvas.bounds.contains(sourceView.convert(point, to: drawingCanvas))
@@ -244,6 +265,15 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
addSubview(selectionLoupe)
pageLoadFailureButton.setTitle("页面加载失败,点击重试", for: .normal)
pageLoadFailureButton.titleLabel?.font = .preferredFont(forTextStyle: .body)
pageLoadFailureButton.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.94)
pageLoadFailureButton.layer.cornerRadius = 10
pageLoadFailureButton.accessibilityIdentifier = "pdf.reader.page.retry"
pageLoadFailureButton.addTarget(self, action: #selector(retryPageLoad), for: .touchUpInside)
pageLoadFailureButton.isHidden = true
addSubview(pageLoadFailureButton)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.delegate = self
tap.require(toFail: zoomView.doubleTapZoomGestureRecognizer)
@@ -261,6 +291,10 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
zoomView.layoutIfNeeded()
textLayer.frame = zoomView.contentView.bounds
drawingCanvas.frame = zoomView.contentView.bounds
pageLoadFailureButton.sizeToFit()
pageLoadFailureButton.bounds.size.width += 32
pageLoadFailureButton.bounds.size.height += 24
pageLoadFailureButton.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
// MARK: -
@@ -279,6 +313,13 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
readerContentTapHandler?(gesture.location(in: self))
}
@objc private func retryPageLoad() { pageLoadRetryHandler?() }
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
// /
!(touch.view?.isDescendant(of: pageLoadFailureButton) ?? false)
}
private func highlightAt(_ point: CGPoint) -> RDPDFReaderAnnotation? {
guard bounds.width > 0, bounds.height > 0 else { return nil }
let normalizedPoint = CGPoint(
@@ -18,6 +18,10 @@ public enum RDPDFReaderPanelPresenter {
layout: RDPDFReaderPanelLayout,
hidesNavigationBar: Bool = true
) {
// UIKit
// presenter
guard presenter.presentedViewController == nil,
!presenter.isBeingDismissed else { return }
let transitionDelegate = RDPDFReaderPanelTransitionDelegate(layout: layout)
objc_setAssociatedObject(controller, &transitionDelegateKey, transitionDelegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.setNavigationBarHidden(hidesNavigationBar, animated: false)
@@ -7,6 +7,7 @@ public enum RDPDFReaderAnnotationPersistenceError: LocalizedError {
case invalidDocument(URL, Error)
case unsupportedDocumentVersion(URL, Int)
case duplicateAnnotationID(String)
case drawingConflict(URL, expectedRevision: Int, actualRevision: Int)
case encodingFailed(Error)
case writeFailed(URL, Error)
@@ -20,6 +21,8 @@ public enum RDPDFReaderAnnotationPersistenceError: LocalizedError {
return "PDF 标注来自更新版本,当前版本无法安全保存。"
case .duplicateAnnotationID:
return "检测到重复的 PDF 标注标识。"
case .drawingConflict:
return "笔迹已在另一阅读器中更新,请重新载入后再试。"
case .encodingFailed:
return "无法编码 PDF 标注。"
case .writeFailed:
@@ -36,8 +39,8 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
}
private static let annotationDocumentVersion = 1
///
///
///
///
private static let annotationPersistenceLock = NSLock()
private let rootURL: URL
private let drawingsURL: URL
@@ -56,6 +59,12 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
}
public func drawingDocument(pageNo: Int) -> RDPDFReaderDrawingDocument {
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
return drawingDocumentLocked(pageNo: pageNo)
}
private func drawingDocumentLocked(pageNo: Int) -> RDPDFReaderDrawingDocument {
let url = drawingsURL.appendingPathComponent("\(pageNo).json")
guard let data = try? Data(contentsOf: url), let document = try? JSONDecoder().decode(RDPDFReaderDrawingDocument.self, from: data) else {
return .init(pageNo: pageNo, paths: [])
@@ -63,30 +72,90 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
return document
}
public func saveDrawingPaths(_ paths: [RDPDFReaderDrawingPath], pageNo: Int) {
@discardableResult
public func saveDrawingPaths(_ paths: [RDPDFReaderDrawingPath], pageNo: Int) -> Result<Void, RDPDFReaderAnnotationPersistenceError> {
saveDrawingDocument(.init(pageNo: pageNo, paths: paths), pageNo: pageNo)
}
public func saveDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageNo: Int) {
guard let data = try? JSONEncoder().encode(document) else { return }
write(data, to: drawingsURL.appendingPathComponent("\(pageNo).json"))
@discardableResult
public func saveDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageNo: Int) -> Result<Void, RDPDFReaderAnnotationPersistenceError> {
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
let url = drawingsURL.appendingPathComponent("\(pageNo).json")
do {
let data = try JSONEncoder().encode(document)
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: url, options: .atomic)
return .success(())
} catch let error as RDPDFReaderAnnotationPersistenceError {
return .failure(error)
} catch {
return .failure(.writeFailed(url, error))
}
}
/// 使
/// revision
@discardableResult
public func saveDrawingDocumentVersioned(
_ document: RDPDFReaderDrawingDocument,
pageNo: Int
) -> Result<RDPDFReaderDrawingDocument, RDPDFReaderAnnotationPersistenceError> {
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
let url = drawingsURL.appendingPathComponent("\(pageNo).json")
let existing = drawingDocumentLocked(pageNo: pageNo)
guard document.revision == existing.revision else {
return .failure(.drawingConflict(
url,
expectedRevision: document.revision,
actualRevision: existing.revision
))
}
let saved = RDPDFReaderDrawingDocument(
pageNo: pageNo,
paths: document.paths,
layers: document.layers,
revision: existing.revision + 1
)
do {
let data = try JSONEncoder().encode(saved)
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: url, options: .atomic)
return .success(saved)
} catch {
return .failure(.writeFailed(url, error))
}
}
public func highlights(pageIndex: Int) -> [RDPDFReaderHighlight] { allHighlights().filter { $0.pageIndex == pageIndex } }
public func allHighlights() -> [RDPDFReaderHighlight] {
guard let data = try? Data(contentsOf: highlightsURL) else { return [] }
return (try? JSONDecoder().decode([RDPDFReaderHighlight].self, from: data)) ?? []
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
return loadHighlightsLocked()
}
public func addHighlight(pageIndex: Int, selectedText: String, color: String, normalizedRect: CGRect) -> RDPDFReaderHighlight {
let item = RDPDFReaderHighlight(id: Int(Date().timeIntervalSince1970 * 1000), pageIndex: pageIndex, selectedText: selectedText, color: color, normalizedRect: normalizedRect)
var items = allHighlights()
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
var items = loadHighlightsLocked()
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
var uniqueID = timestamp
let existingIDs = Set(items.map(\.id))
while existingIDs.contains(uniqueID), uniqueID < Int.max { uniqueID += 1 }
let item = RDPDFReaderHighlight(id: uniqueID, pageIndex: pageIndex, selectedText: selectedText, color: color, normalizedRect: normalizedRect)
items.append(item)
saveHighlights(items)
saveHighlightsLocked(items)
return item
}
public func deleteHighlight(id: Int) { saveHighlights(allHighlights().filter { $0.id != id }) }
public func deleteHighlight(id: Int) {
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
saveHighlightsLocked(loadHighlightsLocked().filter { $0.id != id })
}
/// /
/// 使 `loadAnnotations()`
@@ -194,7 +263,12 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
try saveAnnotationsLocked([])
}
private func saveHighlights(_ highlights: [RDPDFReaderHighlight]) {
private func loadHighlightsLocked() -> [RDPDFReaderHighlight] {
guard let data = try? Data(contentsOf: highlightsURL) else { return [] }
return (try? JSONDecoder().decode([RDPDFReaderHighlight].self, from: data)) ?? []
}
private func saveHighlightsLocked(_ highlights: [RDPDFReaderHighlight]) {
guard let data = try? JSONEncoder().encode(highlights) else { return }
write(data, to: highlightsURL)
}
@@ -23,9 +23,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public var nativeTextDiskCacheVersion = 1
/// OCR 使
public var missingTextSource: RDPDFReaderAnnotationSource = .region
/// SDK PDF PDF
/// 宿 Provider
public var pageDescriptorCacheRadius = 3
/// SDK PDF
/// Provider
public var pageDescriptorCacheRadius = 2
/// OCR 沿
public var ocrMemoryCacheRadius: Int?
/// 宿 Provider
@@ -46,14 +46,25 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private var book: RDPDFReaderBookDescriptor
private var currentTheme: RDPDFReaderThemeOption
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
/// Provider cell
private var pageDescriptorRequestTokens: [Int: UUID] = [:]
private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:]
private var pageDescriptorRequestAttempts: [Int: Int] = [:]
private var pageLoadFailedPages = Set<Int>()
private let maximumPageDescriptorRequestAttempts = 3
/// OCR JSON
private var drawingDocuments: [Int: RDPDFReaderDrawingDocument] = [:]
private var ocrRuns: [Int: [RDPDFReaderTextRun]] = [:]
private var recognizingPages = Set<Int>()
///
private var ocrRequestTokens: [Int: UUID] = [:]
private var bookmarks = Set<Int>()
private var annotations = [RDPDFReaderAnnotation]()
private var annotationsByPage: [Int: [RDPDFReaderAnnotation]] = [:]
private weak var topToolbar: RDPDFReaderKitTopToolView?
private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
private var navigationLoadingIndicator: UIActivityIndicatorView?
private var navigationRequestToken: UUID?
private let drawingToolbar = RDPDFReaderDrawingToolbar()
private var isDrawingMode = false
/// `nil`
@@ -132,6 +143,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
readerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
readerView.dataSource = self
readerView.delegate = self
reloadAnnotationCache()
readerView.currentDisplayType = configuration.displayType
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
// cell =
@@ -152,6 +164,12 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
bookmarks = Set(persistence?.loadBookmarks(for: book.identifier).map(\.pageIndex) ?? [])
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 宿
reloadAnnotations()
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cancelOutstandingOCRRequests()
@@ -160,7 +178,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
let visibleIndexes = Set(visiblePageViews().map(\.tag))
pageDescriptors = pageDescriptors.filter { visibleIndexes.contains($0.key) }
ocrRuns = ocrRuns.filter { visibleIndexes.contains($0.key) }
drawingDocuments = drawingDocuments.filter { visibleIndexes.contains($0.key) }
(pageProvider as? RDPDFKitPageProvider)?.removeCachedImages()
visiblePageViews().forEach { $0.releaseDrawingRenderingCache() }
visibleIndexes.sorted().forEach { startOCRIfNeeded($0) }
}
@@ -169,6 +189,13 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
applyDisplayTypeForCurrentInterface()
}
/// 宿宿
public func reloadAnnotations() {
reloadAnnotationCache()
guard isViewLoaded else { return }
visiblePageViews().forEach { configure($0, at: $0.tag) }
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
let forcePhoneLandscape = traitCollection.userInterfaceIdiom == .phone && size.width > size.height
@@ -245,6 +272,11 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
radius: configuration.ocrMemoryCacheRadius ?? configuration.pageDescriptorCacheRadius
)
trimPageDescriptorCache(around: pageNum, radius: configuration.pageDescriptorCacheRadius)
trimDrawingDocumentCache(around: pageNum, radius: configuration.pageDescriptorCacheRadius)
(pageProvider as? RDPDFKitPageProvider)?.trimPageImages(
around: pageNum,
radius: configuration.pageDescriptorCacheRadius
)
(pageProvider as? RDPDFKitPageProvider)?.trimTextRuns(
around: pageNum,
radius: configuration.pageDescriptorCacheRadius
@@ -258,10 +290,61 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private func trimPageDescriptorCache(around pageIndex: Int, radius: Int) {
let safeRadius = max(0, radius)
let stalePages = pageDescriptors.keys.filter { abs($0 - pageIndex) > safeRadius }
for cachedPage in stalePages {
pageDescriptors.removeValue(forKey: cachedPage)
let visiblePages = Set(visiblePageViews().map(\.tag))
let isStale: (Int) -> Bool = { abs($0 - pageIndex) > safeRadius && !visiblePages.contains($0) }
pageDescriptors.keys.filter(isStale).forEach { pageDescriptors.removeValue(forKey: $0) }
// Provider token
pageDescriptorRequestTokens.keys.filter(isStale).forEach { clearPageDescriptorRequest(for: $0) }
pageDescriptorRequestAttempts.keys.filter(isStale).forEach { pageDescriptorRequestAttempts.removeValue(forKey: $0) }
pageLoadFailedPages = pageLoadFailedPages.filter { !isStale($0) }
}
private func trimDrawingDocumentCache(around pageIndex: Int, radius: Int) {
let safeRadius = max(0, radius)
drawingDocuments = drawingDocuments.filter { abs($0.key - pageIndex) <= safeRadius }
}
private func shouldRetainPageDescriptor(_ pageIndex: Int) -> Bool {
if visiblePageViews().contains(where: { $0.tag == pageIndex }) { return true }
let currentPage = readerView.currentPage
return currentPage >= 0 && abs(pageIndex - currentPage) <= max(0, configuration.pageDescriptorCacheRadius)
}
private func drawingDocument(for pageIndex: Int) -> RDPDFReaderDrawingDocument {
if let cached = drawingDocuments[pageIndex] { return cached }
let document = (annotationPersistence as? RDPDFReaderPersistenceStore)?.drawingDocument(pageNo: pageIndex)
?? .init(pageNo: pageIndex, paths: [])
drawingDocuments[pageIndex] = document
return document
}
private func cacheDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageIndex: Int) {
drawingDocuments[pageIndex] = document
}
private func removePageDescriptorRequestState(for index: Int) {
clearPageDescriptorRequest(for: index)
pageDescriptorRequestAttempts.removeValue(forKey: index)
pageLoadFailedPages.remove(index)
}
private func acceptPageDescriptor(_ descriptor: RDPDFReaderPageDescriptor, at index: Int) {
removePageDescriptorRequestState(for: index)
pageDescriptors[index] = descriptor
// cell
readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
// cell
refreshVisiblePage(index)
}
private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) {
guard pageDescriptorRequestTokens[index] == token else { return }
clearPageDescriptorRequest(for: index)
if attempt >= maximumPageDescriptorRequestAttempts {
pageLoadFailedPages.insert(index)
}
refreshVisiblePage(index)
}
private func trimOCRCache(around pageIndex: Int, radius: Int) {
@@ -282,9 +365,17 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
page.isDrawingMode = isDrawingMode && currentDrawingTool != nil
page.configureDrawing(
pageIndex: index,
document: (annotationPersistence as? RDPDFReaderPersistenceStore)?.drawingDocument(pageNo: index) ?? .init(pageNo: index, paths: []),
documentChanged: { [weak self] document in
(self?.annotationPersistence as? RDPDFReaderPersistenceStore)?.saveDrawingDocument(document, pageNo: index)
document: drawingDocument(for: index),
documentChanged: { [weak self, weak page] document in
guard let self,
let store = self.annotationPersistence as? RDPDFReaderPersistenceStore else { return }
switch store.saveDrawingDocumentVersioned(document, pageNo: index) {
case .success(let saved):
self.cacheDrawingDocument(saved, pageIndex: index)
page?.acknowledgeDrawingRevision(saved.revision)
case .failure(let error):
self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error)
}
}
)
if let currentDrawingTool {
@@ -294,18 +385,70 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
if phase == .began { self?.activeDrawingPage = source }
self?.routeDrawingStroke(from: source, point: point, phase: phase)
}
guard descriptor == nil else { startOCRIfNeeded(index); return }
pageProvider.readerPage(at: index) { [weak self, weak page] descriptor in
DispatchQueue.main.async { [weak self, weak page] in
guard let self, descriptor.index == index else { return }
self.pageDescriptors[index] = descriptor
// cell
self.readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
if let page { self.configure(page, at: index) }
guard descriptor == nil else {
page.setPageLoadFailed(false)
startOCRIfNeeded(index)
return
}
if pageLoadFailedPages.contains(index) {
page.setPageLoadFailed(true) { [weak self, weak page] in
guard let self, let page else { return }
self.pageLoadFailedPages.remove(index)
self.pageDescriptorRequestAttempts[index] = 0
page.setPageLoadFailed(false)
self.configure(page, at: index)
}
return
}
page.setPageLoadFailed(false)
guard pageDescriptorRequestTokens[index] == nil else { return }
let token = UUID()
let attempt = (pageDescriptorRequestAttempts[index] ?? 0) + 1
pageDescriptorRequestAttempts[index] = attempt
pageDescriptorRequestTokens[index] = token
let timeout = DispatchWorkItem { [weak self] in
guard let self, self.pageDescriptorRequestTokens[index] == token else { return }
self.clearPageDescriptorRequest(for: index)
if attempt >= self.maximumPageDescriptorRequestAttempts {
self.pageLoadFailedPages.insert(index)
}
//
self.refreshVisiblePage(index)
}
pageDescriptorRequestTimeouts[index] = timeout
let timeoutSeconds = pow(2.0, Double(attempt - 1)) * 8.0
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeout)
pageProvider.readerPage(at: index) { [weak self] descriptor in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard descriptor.index == index else {
self.handleInvalidPageDescriptor(at: index, token: token, attempt: attempt)
return
}
//
// token
guard self.pageDescriptors[index] == nil else {
if self.pageDescriptorRequestTokens[index] == token {
self.clearPageDescriptorRequest(for: index)
}
return
}
guard self.shouldRetainPageDescriptor(index) else {
if self.pageDescriptorRequestTokens[index] == token {
self.clearPageDescriptorRequest(for: index)
}
return
}
self.acceptPageDescriptor(descriptor, at: index)
}
}
}
private func clearPageDescriptorRequest(for index: Int) {
pageDescriptorRequestTokens.removeValue(forKey: index)
pageDescriptorRequestTimeouts.removeValue(forKey: index)?.cancel()
}
private func renderedImage(_ image: UIImage?) -> UIImage? {
guard let image else { return nil }
return configuration.pageImageTransform?(image, currentTheme) ?? image
@@ -373,9 +516,22 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
.filter { $0.tag >= 0 }
}
private func annotations(for index: Int) -> [RDPDFReaderAnnotation] {
do { return try annotationPersistence?.loadAnnotations().filter { $0.pageIndex == index } ?? [] }
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error); return [] }
private func annotations(for index: Int) -> [RDPDFReaderAnnotation] { annotationsByPage[index] ?? [] }
private func reloadAnnotationCache() {
guard let annotationPersistence else {
annotations = []
annotationsByPage = [:]
return
}
do {
annotations = try annotationPersistence.loadAnnotations()
annotationsByPage = Dictionary(grouping: annotations, by: \.pageIndex)
} catch {
annotations = []
annotationsByPage = [:]
delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error)
}
}
private func toggleBookmark() {
@@ -389,10 +545,14 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private func showNavigation() {
if let asyncOutlineProvider = pageProvider as? RDPDFReaderAsyncOutlineProviding {
guard navigationLoadingIndicator == nil else { return }
let token = UUID()
navigationRequestToken = token
showNavigationLoading()
asyncOutlineProvider.readerOutlineItems { [weak self] outline in
DispatchQueue.main.async {
guard let self else { return }
guard self.navigationRequestToken == token else { return }
self.hideNavigationLoading()
self.presentNavigation(outline: outline)
}
@@ -432,6 +592,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
navigationLoadingIndicator?.stopAnimating()
navigationLoadingIndicator?.removeFromSuperview()
navigationLoadingIndicator = nil
navigationRequestToken = nil
}
private func showSettings() {
@@ -587,8 +748,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
}
private func showAnnotations() {
guard let annotationPersistence else { return }
let panel = RDPDFReaderAnnotationListViewController { (try? annotationPersistence.loadAnnotations()) ?? [] }
guard annotationPersistence != nil else { return }
let panel = RDPDFReaderAnnotationListViewController { [weak self] in self?.annotations ?? [] }
panel.onSelectAnnotation = { [weak self] item in self?.readerView.transitionToPage(pageNum: item.pageIndex, animated: false) }
panel.onDeleteAnnotation = { [weak self] item in self?.delete(item) }
RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .navigation, hidesNavigationBar: false)
@@ -609,12 +770,20 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private func add(_ selection: RDPDFReaderImageTextSelection, page: Int, color: String = RDPDFReaderPageView.defaultHighlightColor, note: String?) {
guard let annotationPersistence else { return }
do { _ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source)); refreshVisiblePage(page) }
do {
_ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source))
reloadAnnotationCache()
refreshVisiblePage(page)
}
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}
private func delete(_ annotation: RDPDFReaderAnnotation) {
do { _ = try annotationPersistence?.deleteAnnotation(id: annotation.id); refreshVisiblePage(annotation.pageIndex) }
do {
_ = try annotationPersistence?.deleteAnnotation(id: annotation.id)
reloadAnnotationCache()
refreshVisiblePage(annotation.pageIndex)
}
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}
@@ -638,6 +807,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
item.note = note
do {
_ = try self?.annotationPersistence?.updateAnnotation(item)
self?.reloadAnnotationCache()
self?.refreshVisiblePage(item.pageIndex)
} catch {
if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }