Files
omnivore/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift
2024-07-01 13:54:14 +08:00

624 lines
23 KiB
Swift

import Combine
import Models
import SwiftUI
import Utils
// swiftlint:disable file_length type_body_length
#if os(iOS)
import PSPDFKit
import PSPDFKitUI
import Services
import Views
@MainActor
struct PDFViewer: View {
enum SettingsKeys: String {
case pageTransitionKey = "PDFViewer.pageTransition"
case pageModeKey = "PDFViewer.pageModeKey"
case scrollDirectionKey = "PDFViewer.scrollDirectionKey"
case spreadFittingKey = "PDFViewer.spreadFittingKey"
func storedValue() -> UInt {
UInt(UserDefaults.standard.integer(forKey: rawValue))
}
}
final class PDFStateObject: ObservableObject {
@Published var document: Document?
@Published var coordinator: PDFViewCoordinator?
@Published var controllerNeedsConfig = true
}
@EnvironmentObject var dataService: DataService
struct ShareLink: Identifiable {
let id: UUID
let url: URL
}
let viewModel: PDFViewerViewModel
@StateObject var pdfStateObject = PDFStateObject()
@State var readerView: Bool = false
@State private var shareLink: ShareLink?
@State private var errorMessage: String?
@State private var showNotebookView = false
@State private var showLabelsModal = false
@State private var hasPerformedHighlightMutations = false
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@State private var annotation = ""
@State private var addNoteHighlight: Highlight?
@State private var showAnnotationModal = false
@State private var showSettingsModal = false
@Environment(\.presentationCoordinator) var presentationCoordinator
init(viewModel: PDFViewerViewModel) {
self.viewModel = viewModel
}
func saveSettings(configuration: PDFConfiguration) {
let defaults = UserDefaults.standard
defaults.set(configuration.pageTransition.rawValue, forKey: SettingsKeys.pageTransitionKey.rawValue)
defaults.set(configuration.pageMode.rawValue, forKey: SettingsKeys.pageModeKey.rawValue)
defaults.set(configuration.scrollDirection.rawValue, forKey: SettingsKeys.scrollDirectionKey.rawValue)
defaults.set(configuration.spreadFitting.rawValue, forKey: SettingsKeys.spreadFittingKey.rawValue)
}
func restoreSettings(builder: PDFConfigurationBuilder) {
if let pageTransition = PageTransition(rawValue: SettingsKeys.pageTransitionKey.storedValue()) {
builder.pageTransition = pageTransition
}
if let pageMode = PageMode(rawValue: SettingsKeys.pageModeKey.storedValue()) {
builder.pageMode = pageMode
}
if let scrollDirection = ScrollDirection(rawValue: SettingsKeys.scrollDirectionKey.storedValue()) {
builder.scrollDirection = scrollDirection
}
if let spreadFitting = PDFConfiguration.SpreadFitting(
rawValue: Int(SettingsKeys.spreadFittingKey.storedValue())
) {
builder.spreadFitting = spreadFitting
}
}
var body: some View {
if let document = pdfStateObject.document, let coordinator = pdfStateObject.coordinator {
PDFView(document: document)
.useParentNavigationBar(true)
.updateConfiguration { builder in
builder.settingsOptions = [.pageTransition, .pageMode, .scrollDirection, .spreadFitting, .appearance]
restoreSettings(builder: builder)
builder.textSelectionShouldSnapToWord = true
builder.shouldAskForAnnotationUsername = false
}
.updateControllerConfiguration { controller in
saveSettings(configuration: controller.configuration)
// Store config state so we only run this update closure once
guard pdfStateObject.controllerNeedsConfig else { return }
coordinator.setController(controller: controller, dataService: dataService)
let barButtonItems = [
UIBarButtonItem(
image: UIImage(systemName: "textformat"),
style: .plain,
target: coordinator,
action: #selector(PDFViewCoordinator.displaySettingsSheet)
),
UIBarButtonItem(
image: UIImage(systemName: "book"),
style: .plain,
target: coordinator,
action: #selector(PDFViewCoordinator.toggleReaderView)
),
UIBarButtonItem(
image: UIImage(systemName: "magnifyingglass"),
style: .plain,
target: controller.searchButtonItem.target,
action: controller.searchButtonItem.action
),
UIBarButtonItem(
image: UIImage(named: "notebook", in: Bundle(url: ViewsPackage.bundleURL), with: nil),
style: .plain,
target: coordinator,
action: #selector(PDFViewCoordinator.toggleNotebookView)
),
UIBarButtonItem(
image: UIImage(named: "label", in: Bundle(url: ViewsPackage.bundleURL), with: nil),
style: .plain,
target: coordinator,
action: #selector(PDFViewCoordinator.toggleLabelsView)
)
]
let leftButtonItems = [
UIBarButtonItem(
image: UIImage(named: "chevron-right", in: Bundle(url: ViewsPackage.bundleURL), with: nil),
style: .plain,
target: coordinator,
action: #selector(PDFViewCoordinator.pop)
)
]
document.areAnnotationsEnabled = true
coordinator.viewer = self
if viewModel.pdfItem.readingProgressAnchor > 0 {
let pageIndex = UInt(viewModel.pdfItem.readingProgressAnchor)
controller.setPageIndex(pageIndex, animated: false)
}
controller.navigationItem.setLeftBarButtonItems(leftButtonItems, for: .document, animated: false)
controller.navigationItem.setRightBarButtonItems(barButtonItems, for: .document, animated: false)
pdfStateObject.controllerNeedsConfig = false
}
.onShouldShowMenuItemsForSelectedText(perform: { pageView, menuItems, selectedText in
let copy = menuItems.first(where: { $0.identifier == "Copy" })
let define = menuItems.first(where: { $0.identifier == "Define" })
let highlight = MenuItem(title: LocalText.genericHighlight, block: {
_ = coordinator.highlightSelection(
pageView: pageView,
selectedText: selectedText,
dataService: dataService
)
})
define?.title = "Lookup"
return [copy, highlight, define].compactMap { $0 }
})
.onShouldShowMenuItemsForSelectedAnnotations(perform: { _, menuItems, annotations in
var result = [MenuItem]()
if let copy = menuItems.first(where: { $0.identifier == "Copy" }) {
result.append(copy)
}
let note = MenuItem(title: "Note", block: {
if let highlight = annotations?.compactMap({ $0 as? HighlightAnnotation }).first,
let customHighlight = highlight.customData?["omnivoreHighlight"] as? [String: String],
let highlightID = customHighlight["id"]?.lowercased(),
let selectedHighlight = viewModel.findHighlight(dataService: dataService, highlightID: highlightID)
{
addNoteHighlight = selectedHighlight
annotation = selectedHighlight.annotation ?? ""
showAnnotationModal = true
} else {
errorMessage = "Unable to find highlight"
showErrorAlertMessage = true
}
})
result.append(note)
let remove = MenuItem(title: "Remove", block: {
coordinator.remove(dataService: dataService, annotations: annotations)
})
result.append(remove)
return result
})
.sheet(isPresented: $showAnnotationModal) {
NavigationView {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
// annotationSaveTransactionID = UUID()
if let highlightID = addNoteHighlight?.id {
viewModel.updateAnnotation(
highlightID: highlightID,
annotation: annotation,
dataService: dataService
)
showAnnotationModal = false
}
},
onCancel: {
annotation = ""
addNoteHighlight = nil
showAnnotationModal = false
},
errorAlertMessage: $errorAlertMessage,
showErrorAlertMessage: $showErrorAlertMessage
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
.formSheet(isPresented: $showSettingsModal, modalSize: CGSize(width: 400, height: 475)) {
NavigationView {
PDFSettingsView(pdfViewController: coordinator.controller)
}
.navigationViewStyle(StackNavigationViewStyle())
}
.sheet(isPresented: $readerView, content: {
PDFReaderViewController(document: document)
})
.accentColor(Color(red: 255 / 255.0, green: 234 / 255.0, blue: 159 / 255.0))
.sheet(item: $shareLink) {
ShareSheet(activityItems: [$0.url])
}
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView(
viewModel: NotebookViewModel(item: viewModel.pdfItem.item),
hasHighlightMutations: $hasPerformedHighlightMutations,
onDeleteHighlight: { highlightId in
coordinator.removeHighlightFromPDF(highlightId: highlightId)
}
)
}
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(viewModel.pdfItem.item), onSave: { _ in
showLabelsModal = false
})
}.task {
viewModel.updateItemReadProgress(
dataService: dataService,
percent: viewModel.pdfItem.item.readingProgress,
anchorIndex: Int(viewModel.pdfItem.item.readingProgressAnchor)
)
}
} else if let errorMessage = errorMessage {
Text(errorMessage)
} else {
ProgressView()
.task {
// NOTE: the issue here is the PDF is downloaded, but saved to a URL we don't know about
// because it is changed.
let pdfURL = await viewModel.downloadPDF(dataService: dataService)
if let pdfURL = pdfURL {
let document = HighlightedDocument(url: pdfURL, viewModel: viewModel)
pdfStateObject.document = document
pdfStateObject.coordinator = PDFViewCoordinator(document: document, viewModel: viewModel)
} else {
errorMessage = "Unable to download PDF: \(pdfURL?.description ?? "")"
}
}
}
}
func onNotebookViewDismissal() {
guard hasPerformedHighlightMutations else { return }
hasPerformedHighlightMutations.toggle()
}
@MainActor
class PDFViewCoordinator: NSObject, PDFDocumentViewControllerDelegate, PDFViewControllerDelegate {
let document: Document
let viewModel: PDFViewerViewModel
var subscriptions = Set<AnyCancellable>()
public var viewer: PDFViewer?
public var controller: PDFViewController?
init(document: Document, viewModel: PDFViewerViewModel) {
self.document = document
self.viewModel = viewModel
}
func setController(controller: PDFViewController, dataService: DataService) {
self.controller = controller
controller.pageIndexPublisher.sink { event in
DispatchQueue.main.async {
let pageIndex = Int(event.pageIndex)
if let totalPageCount = controller.document?.pageCount {
let percent = min(100, max(0, ((Double(pageIndex) + 1.0) / Double(totalPageCount)) * 100.0))
self.viewModel.updateItemReadProgress(
dataService: dataService,
percent: percent,
anchorIndex: pageIndex,
force: true
)
}
}
}.store(in: &subscriptions)
controller.documentPublisher.sink { document in
print("document published", document.isValid)
}.store(in: &subscriptions)
}
func removeHighlightFromPDF(highlightId: String) {
for pageIndex in 0 ..< document.pageCount {
let pageHighlights = document.annotations(at: pageIndex, type: HighlightAnnotation.self)
for annotation in pageHighlights {
if let customHighlight = annotation.customData?["omnivoreHighlight"] as? [String: String] {
if customHighlight["id"]?.lowercased() == highlightId {
if !document.remove(annotations: [annotation]) {
Snackbar.show(message: "Error removing highlight", dismissAfter: 2000)
}
}
}
}
}
}
func highlightsOverlap(left: HighlightAnnotation, right: HighlightAnnotation) -> Bool {
for rect in left.rects ?? [] {
for hrrect in right.rects ?? [] {
if rect.intersects(hrrect) {
return true
}
}
}
return false
}
func overlappingHighlights(pageView: PDFPageView, highlight: HighlightAnnotation) -> [HighlightAnnotation] {
var result = [HighlightAnnotation]()
let existingId = (highlight.customData?["omnivoreHighlight"] as? [String: String])?["id"]?.lowercased()
let pageHighlights = document.annotations(at: pageView.pageIndex, type: HighlightAnnotation.self)
for annotation in pageHighlights {
if let customHighlight = annotation.customData?["omnivoreHighlight"] as? [String: String] {
if customHighlight["id"]?.lowercased() == existingId {
continue
}
}
if highlightsOverlap(left: highlight, right: annotation) {
result.append(annotation)
}
}
return result
}
func heightBefore(pageIndex: PageIndex) -> Double {
var totalHeight = 0.0
for idx in 0 ..< pageIndex {
if let page = document.pageInfoForPage(at: idx) {
totalHeight += page.size.height
}
}
return totalHeight
}
func documentTotalHeight() -> Double {
var totalHeight = 0.0
for idx in 0 ..< document.pageCount {
if let page = document.pageInfoForPage(at: idx) {
totalHeight += page.size.height
}
}
return totalHeight
}
func highlightTop(pageView: PDFPageView, highlight: HighlightAnnotation) -> Double {
if let pageInfo = pageView.pageInfo {
return pageInfo.size.height - highlight.boundingBox.minY
}
return 0.0
}
// swiftlint:disable:next function_body_length
func highlightSelection(pageView: PDFPageView, selectedText: String, dataService: DataService) -> String {
let highlightID = UUID().uuidString.lowercased()
let quote = quoteFromSelectedText(selectedText)
let shortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8)
let highlight = HighlightAnnotation.textOverlayAnnotation(with: pageView.selectionView.selectedGlyphs)!
highlight.pageIndex = pageView.pageIndex
highlight.customData = [
"omnivoreHighlight": [
"id": highlightID,
"shortId": shortId,
"quote": quote,
"articleId": viewModel.pdfItem.itemID
]
]
document.add(annotations: [highlight])
let overlapping = overlappingHighlights(pageView: pageView, highlight: highlight)
if let patchData = try? highlight.generateInstantJSON(), let patch = String(data: patchData, encoding: .utf8) {
let top = highlightTop(pageView: pageView, highlight: highlight)
let positionPercent = (heightBefore(pageIndex: pageView.pageIndex) + top) / documentTotalHeight()
if overlapping.isEmpty {
viewModel.createHighlight(
dataService: dataService,
shortId: shortId,
highlightID: highlightID,
quote: quote,
patch: patch,
positionPercent: positionPercent,
positionAnchorIndex: Int(pageView.pageIndex)
)
} else {
let overlappingRects = overlapping.map(\.rects).compactMap { $0 }.flatMap { $0 }
let rects = overlappingRects + (highlight.rects ?? [])
let boundingBox = rects.reduce(CGRect.null) { $0.union($1) }
if let mergedHighlight = HighlightAnnotation.textOverlayAnnotation(
withRects: rects,
boundingBox: boundingBox,
pageIndex: Int(pageView.pageIndex)
) {
let top = boundingBox.minY
let positionPercent = (heightBefore(pageIndex: pageView.pageIndex) + top) / documentTotalHeight()
mergedHighlight.customData = highlight.customData
document.add(annotations: [mergedHighlight])
document.remove(annotations: overlapping + [highlight])
viewModel.mergeHighlight(
dataService: dataService,
shortId: shortId,
highlightID: highlightID,
quote: quote,
patch: patch,
positionPercent: positionPercent,
positionAnchorIndex: Int(pageView.pageIndex),
overlapHighlightIdList: highlightIds(overlapping)
)
}
}
}
DispatchQueue.main.async {
pageView.selectionView.discardSelection(animated: false)
}
return shortId
}
public func remove(dataService: DataService, annotations: [Annotation]?) {
if let annotations = annotations {
document.remove(annotations: annotations)
viewModel.removeHighlights(
dataService: dataService,
highlightIds: highlightIds(annotations.compactMap { $0 as? HighlightAnnotation })
)
}
}
@objc public func pop() {
if let viewer = self.viewer {
viewer.presentationCoordinator.dismiss()
}
}
@objc public func toggleReaderView() {
if let viewer = self.viewer {
viewer.readerView = !viewer.readerView
}
}
@objc public func displaySettingsSheet() {
if let viewer = self.viewer {
viewer.showSettingsModal = true
}
}
@objc public func toggleNotebookView() {
if let viewer = self.viewer {
viewer.showNotebookView = !viewer.showNotebookView
}
}
@objc public func toggleLabelsView() {
if let viewer = self.viewer {
viewer.showLabelsModal = !viewer.showLabelsModal
}
}
func shortHighlightIds(_ annotations: [HighlightAnnotation]) -> [String] {
annotations.compactMap { ($0.customData?["omnivoreHighlight"] as? [String: String])?["shortId"] }
}
func highlightIds(_ annotations: [HighlightAnnotation]) -> [String] {
annotations.compactMap { ($0.customData?["omnivoreHighlight"] as? [String: String])?["id"]?.lowercased() }
}
private func quoteFromSelectedText(_ selectedText: String) -> String {
let result = selectedText
.replacingOccurrences(of: "-\r\n", with: "")
.replacingOccurrences(of: "-\r", with: "")
.replacingOccurrences(of: "-\n", with: "")
.replacingOccurrences(of: "\r\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\n", with: " ")
return result
}
}
}
final class HighlightedDocument: Document {
var highlightsApplied = false
let viewModel: PDFViewerViewModel
init(url: URL, viewModel: PDFViewerViewModel) {
self.viewModel = viewModel
super.init(dataProviders: [Self.dataProvider(forUrl: url)], loadCheckpointIfAvailable: false)
}
private static func dataProvider(forUrl url: URL) -> DataProviding {
if url.isFileURL {
return FileDataProvider(fileURL: url)
} else {
return URLDataProvider(url: url)
}
}
override func didCreateDocumentProvider(_ documentProvider: PDFDocumentProvider) -> PDFDocumentProvider {
if !highlightsApplied {
DispatchQueue.main.async { [self] in
self.applyHighlights(documentProvider: documentProvider)
}
}
return documentProvider
}
private func applyHighlights(documentProvider: PDFDocumentProvider) {
viewModel.loadHighlightPatches { [weak self] highlightPatches in
var annnotations: [Annotation] = []
for patch in highlightPatches {
guard let data = patch.data(using: String.Encoding.utf8) else { continue }
let annotation = try? Annotation(fromInstantJSON: data, documentProvider: documentProvider)
guard let annotation = annotation else { continue }
annnotations.append(annotation)
}
self?.add(annotations: annnotations)
self?.highlightsApplied = true
}
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct ShareSheet: UIViewControllerRepresentable {
typealias Callback = (
_ activityType: UIActivity.ActivityType?,
_ completed: Bool,
_ returnedItems: [Any]?,
_ error: Error?
) -> Void
let activityItems: [Any]
let applicationActivities: [UIActivity]? = nil
let excludedActivityTypes: [UIActivity.ActivityType]? = nil
let callback: Callback? = nil
func makeUIViewController(context _: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: activityItems,
applicationActivities: applicationActivities
)
controller.excludedActivityTypes = excludedActivityTypes
controller.completionWithItemsHandler = callback
return controller
}
func updateUIViewController(_: UIActivityViewController, context _: Context) {
// nothing to do here
}
}
struct ShareSheet_Previews: PreviewProvider {
static var previews: some View {
ShareSheet(activityItems: ["A string" as NSString])
}
}
#elseif os(macOS)
struct PDFViewer: View {
let remoteURL: URL
let viewModel: PDFViewerViewModel
var body: some View {
Text(remoteURL.absoluteString)
}
}
#endif