983 lines
31 KiB
Swift
983 lines
31 KiB
Swift
|
|
import AppKit
|
||
|
|
import Foundation
|
||
|
|
import IHatePDFsCore
|
||
|
|
import PDFKit
|
||
|
|
import SwiftUI
|
||
|
|
|
||
|
|
enum SidebarMode: String, CaseIterable, Identifiable {
|
||
|
|
case pages
|
||
|
|
case annotations
|
||
|
|
|
||
|
|
var id: String { rawValue }
|
||
|
|
}
|
||
|
|
|
||
|
|
enum CommentFilter: String, CaseIterable, Identifiable {
|
||
|
|
case all
|
||
|
|
case withComments
|
||
|
|
case withoutComments
|
||
|
|
|
||
|
|
var id: String { rawValue }
|
||
|
|
|
||
|
|
var title: String {
|
||
|
|
switch self {
|
||
|
|
case .all: return "All"
|
||
|
|
case .withComments: return "With Comments"
|
||
|
|
case .withoutComments: return "No Comment"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
enum AnnotationPlacementTool: Equatable {
|
||
|
|
case freeText
|
||
|
|
}
|
||
|
|
|
||
|
|
private enum AppDefaults {
|
||
|
|
static let documentSidebarStates = "IHatePDFs.documentSidebarStates.v1"
|
||
|
|
|
||
|
|
static func sidebarPreference(for key: String) -> SidebarPreference? {
|
||
|
|
guard let data = UserDefaults.standard.data(forKey: documentSidebarStates),
|
||
|
|
let states = try? JSONDecoder().decode([String: SidebarPreference].self, from: data)
|
||
|
|
else {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
return states[key]
|
||
|
|
}
|
||
|
|
|
||
|
|
static func setSidebarPreference(_ preference: SidebarPreference, for key: String) {
|
||
|
|
let existingData = UserDefaults.standard.data(forKey: documentSidebarStates)
|
||
|
|
var states = existingData
|
||
|
|
.flatMap { try? JSONDecoder().decode([String: SidebarPreference].self, from: $0) }
|
||
|
|
?? [:]
|
||
|
|
states[key] = preference
|
||
|
|
|
||
|
|
guard let data = try? JSONEncoder().encode(states) else { return }
|
||
|
|
UserDefaults.standard.set(data, forKey: documentSidebarStates)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private enum SidebarWidthBucket: String {
|
||
|
|
case compact
|
||
|
|
case regular
|
||
|
|
case wide
|
||
|
|
|
||
|
|
init(width: CGFloat) {
|
||
|
|
if width < 960 {
|
||
|
|
self = .compact
|
||
|
|
} else if width < 1280 {
|
||
|
|
self = .regular
|
||
|
|
} else {
|
||
|
|
self = .wide
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private struct SidebarPreference: Codable, Equatable {
|
||
|
|
var showLeftSidebar: Bool
|
||
|
|
var showCommentsSidebar: Bool
|
||
|
|
|
||
|
|
static let defaultReading = SidebarPreference(showLeftSidebar: false, showCommentsSidebar: false)
|
||
|
|
}
|
||
|
|
|
||
|
|
struct AnnotationEditorContext: Identifiable {
|
||
|
|
let id = UUID()
|
||
|
|
let title: String
|
||
|
|
let annotations: [PDFAnnotation]
|
||
|
|
let pages: [PDFPage]
|
||
|
|
let isNewAnnotation: Bool
|
||
|
|
let allowsDelete: Bool
|
||
|
|
let initialText: String
|
||
|
|
let initialAuthor: String
|
||
|
|
|
||
|
|
var primaryAnnotation: PDFAnnotation? { annotations.first }
|
||
|
|
var primaryPage: PDFPage? { pages.first }
|
||
|
|
}
|
||
|
|
|
||
|
|
@MainActor
|
||
|
|
final class AppState: NSObject, ObservableObject {
|
||
|
|
@Published var document: PDFDocument?
|
||
|
|
@Published var documentURL: URL?
|
||
|
|
@Published var pdfView: PDFView?
|
||
|
|
@Published var annotations: [AnnotationSnapshot] = []
|
||
|
|
@Published var selectedAnnotationID: String?
|
||
|
|
@Published var activeEditor: AnnotationEditorContext?
|
||
|
|
@Published var placementTool: AnnotationPlacementTool?
|
||
|
|
@Published var showLeftSidebar = false {
|
||
|
|
didSet {
|
||
|
|
persistSidebarPreferenceIfNeeded()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
@Published var showCommentsSidebar = false {
|
||
|
|
didSet {
|
||
|
|
persistSidebarPreferenceIfNeeded()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
@Published var sidebarMode: SidebarMode = .pages
|
||
|
|
@Published var searchText = ""
|
||
|
|
@Published var showToolbarSearch = false
|
||
|
|
@Published var searchResults: [PDFSelection] = []
|
||
|
|
@Published var currentSearchIndex = 0
|
||
|
|
@Published var pageText = "1"
|
||
|
|
@Published var currentPageIndex = 0
|
||
|
|
@Published var commentSearchText = ""
|
||
|
|
@Published var commentFilter: CommentFilter = .all
|
||
|
|
@Published var selectedKindFilter: AcademicAnnotationKind?
|
||
|
|
@Published var selectedAuthorFilter = "All Authors"
|
||
|
|
@Published var selectedStatusFilter = ReviewState.allStatuses
|
||
|
|
@Published var collapsedPageIndexes: Set<Int> = []
|
||
|
|
@Published var statusMessage = "Open a PDF to begin."
|
||
|
|
|
||
|
|
private var pageObserver: NSObjectProtocol?
|
||
|
|
private var selectionObserver: NSObjectProtocol?
|
||
|
|
private var sidebarWidthBucket: SidebarWidthBucket = .regular
|
||
|
|
private var isApplyingSidebarPreference = false
|
||
|
|
private var hoveredAnnotationID: String?
|
||
|
|
|
||
|
|
var displayTitle: String {
|
||
|
|
documentURL?.lastPathComponent ?? "I Hate PDFs"
|
||
|
|
}
|
||
|
|
|
||
|
|
var pageCount: Int {
|
||
|
|
document?.pageCount ?? 0
|
||
|
|
}
|
||
|
|
|
||
|
|
var authors: [String] {
|
||
|
|
let values = Set(annotations.map(\.author).filter { !$0.isEmpty })
|
||
|
|
return ["All Authors"] + values.sorted()
|
||
|
|
}
|
||
|
|
|
||
|
|
var statuses: [String] {
|
||
|
|
let values = Set(annotations.map { ReviewState.label(for: $0.status) }.filter { !$0.isEmpty })
|
||
|
|
let preferred = [ReviewState.notReviewed, ReviewState.reviewed].filter(values.contains)
|
||
|
|
let custom = values.subtracting(preferred).sorted()
|
||
|
|
return [ReviewState.allStatuses] + preferred + custom
|
||
|
|
}
|
||
|
|
|
||
|
|
var filteredAnnotations: [AnnotationSnapshot] {
|
||
|
|
annotations.filter { item in
|
||
|
|
switch commentFilter {
|
||
|
|
case .all:
|
||
|
|
break
|
||
|
|
case .withComments:
|
||
|
|
guard item.hasComment else { return false }
|
||
|
|
case .withoutComments:
|
||
|
|
guard !item.hasComment else { return false }
|
||
|
|
}
|
||
|
|
|
||
|
|
if let selectedKindFilter, item.kind != selectedKindFilter {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
if selectedAuthorFilter != "All Authors", item.author != selectedAuthorFilter {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
if !ReviewState.matches(item.status, filter: selectedStatusFilter) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
let query = commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
|
if !query.isEmpty {
|
||
|
|
let haystack = [
|
||
|
|
item.contents,
|
||
|
|
item.author,
|
||
|
|
item.kind.displayName,
|
||
|
|
item.pageLabel
|
||
|
|
].joined(separator: " ")
|
||
|
|
guard haystack.localizedCaseInsensitiveContains(query) else { return false }
|
||
|
|
}
|
||
|
|
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var topLevelComments: [AnnotationSnapshot] {
|
||
|
|
filteredAnnotations.filter { !$0.isReply }
|
||
|
|
}
|
||
|
|
|
||
|
|
var repliesByParent: [String: [AnnotationSnapshot]] {
|
||
|
|
Dictionary(grouping: filteredAnnotations.filter(\.isReply), by: \.parentID!)
|
||
|
|
}
|
||
|
|
|
||
|
|
func attachPDFView(_ view: PDFView) {
|
||
|
|
if pdfView === view { return }
|
||
|
|
|
||
|
|
pdfView = view
|
||
|
|
configure(view)
|
||
|
|
view.document = document
|
||
|
|
|
||
|
|
pageObserver = NotificationCenter.default.addObserver(
|
||
|
|
forName: .PDFViewPageChanged,
|
||
|
|
object: view,
|
||
|
|
queue: .main
|
||
|
|
) { [weak self] _ in
|
||
|
|
Task { @MainActor in self?.updateCurrentPageState() }
|
||
|
|
}
|
||
|
|
|
||
|
|
selectionObserver = NotificationCenter.default.addObserver(
|
||
|
|
forName: .PDFViewSelectionChanged,
|
||
|
|
object: view,
|
||
|
|
queue: .main
|
||
|
|
) { [weak self] _ in
|
||
|
|
Task { @MainActor in
|
||
|
|
guard self?.placementTool == nil else { return }
|
||
|
|
self?.statusMessage = "Selection ready for annotation."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func updateWindowWidth(_ width: CGFloat) {
|
||
|
|
guard width.isFinite, width > 0 else { return }
|
||
|
|
|
||
|
|
let bucket = SidebarWidthBucket(width: width)
|
||
|
|
guard bucket != sidebarWidthBucket else { return }
|
||
|
|
|
||
|
|
sidebarWidthBucket = bucket
|
||
|
|
applySidebarPreferenceForCurrentDocument()
|
||
|
|
}
|
||
|
|
|
||
|
|
func openDocument() {
|
||
|
|
let panel = NSOpenPanel()
|
||
|
|
panel.allowedContentTypes = [.pdf]
|
||
|
|
panel.allowsMultipleSelection = false
|
||
|
|
panel.canChooseDirectories = false
|
||
|
|
panel.title = "Open PDF"
|
||
|
|
|
||
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
|
|
loadDocument(from: url)
|
||
|
|
}
|
||
|
|
|
||
|
|
func loadDocument(from url: URL) {
|
||
|
|
guard let pdf = PDFDocument(url: url) else {
|
||
|
|
showAlert(title: "Unable to Open PDF", message: "The selected file could not be opened as a PDF.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
document = pdf
|
||
|
|
documentURL = url
|
||
|
|
applySidebarPreferenceForCurrentDocument()
|
||
|
|
pdfView?.document = pdf
|
||
|
|
pdfView?.goToFirstPage(nil)
|
||
|
|
pageText = "1"
|
||
|
|
currentPageIndex = 0
|
||
|
|
searchText = ""
|
||
|
|
showToolbarSearch = false
|
||
|
|
searchResults = []
|
||
|
|
selectedAnnotationID = nil
|
||
|
|
activeEditor = nil
|
||
|
|
placementTool = nil
|
||
|
|
refreshAnnotations()
|
||
|
|
statusMessage = "Opened \(url.lastPathComponent)."
|
||
|
|
}
|
||
|
|
|
||
|
|
func closeDocument() {
|
||
|
|
persistSidebarPreferenceIfNeeded()
|
||
|
|
document = nil
|
||
|
|
documentURL = nil
|
||
|
|
annotations = []
|
||
|
|
selectedAnnotationID = nil
|
||
|
|
activeEditor = nil
|
||
|
|
placementTool = nil
|
||
|
|
searchResults = []
|
||
|
|
searchText = ""
|
||
|
|
showToolbarSearch = false
|
||
|
|
pageText = "1"
|
||
|
|
currentPageIndex = 0
|
||
|
|
pdfView?.document = nil
|
||
|
|
applySidebarPreference(.defaultReading)
|
||
|
|
statusMessage = "Open a PDF to begin."
|
||
|
|
}
|
||
|
|
|
||
|
|
func saveDocument() {
|
||
|
|
guard let document else { return }
|
||
|
|
|
||
|
|
if let url = documentURL {
|
||
|
|
let alert = NSAlert()
|
||
|
|
alert.alertStyle = .warning
|
||
|
|
alert.messageText = "Overwrite Original PDF?"
|
||
|
|
alert.informativeText = "Annotations will be written directly into \(url.lastPathComponent). Use Save As to create a separate annotated copy."
|
||
|
|
alert.addButton(withTitle: "Save")
|
||
|
|
alert.addButton(withTitle: "Cancel")
|
||
|
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||
|
|
|
||
|
|
write(document, to: url)
|
||
|
|
} else {
|
||
|
|
saveDocumentAs()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func saveDocumentAs() {
|
||
|
|
guard let document else { return }
|
||
|
|
|
||
|
|
let panel = NSSavePanel()
|
||
|
|
panel.allowedContentTypes = [.pdf]
|
||
|
|
panel.canCreateDirectories = true
|
||
|
|
panel.title = "Save Annotated PDF"
|
||
|
|
panel.nameFieldStringValue = suggestedAnnotatedFilename()
|
||
|
|
|
||
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
|
|
write(document, to: url)
|
||
|
|
documentURL = url
|
||
|
|
persistSidebarPreferenceIfNeeded()
|
||
|
|
}
|
||
|
|
|
||
|
|
func shareDocument() {
|
||
|
|
guard let document else { return }
|
||
|
|
guard let url = documentURL else {
|
||
|
|
saveDocumentAs()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let alert = NSAlert()
|
||
|
|
alert.alertStyle = .informational
|
||
|
|
alert.messageText = "Share Annotated PDF?"
|
||
|
|
alert.informativeText = "Save annotations to \(url.lastPathComponent) before sharing so recipients see the latest comments."
|
||
|
|
alert.addButton(withTitle: "Save and Share")
|
||
|
|
alert.addButton(withTitle: "Share Existing File")
|
||
|
|
alert.addButton(withTitle: "Cancel")
|
||
|
|
|
||
|
|
switch alert.runModal() {
|
||
|
|
case .alertFirstButtonReturn:
|
||
|
|
guard document.write(to: url) else {
|
||
|
|
showAlert(title: "Save Failed", message: "The PDF could not be written to \(url.path).")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
refreshAnnotations()
|
||
|
|
case .alertSecondButtonReturn:
|
||
|
|
break
|
||
|
|
default:
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
presentSharePicker(for: url)
|
||
|
|
statusMessage = "Ready to share \(url.lastPathComponent)."
|
||
|
|
}
|
||
|
|
|
||
|
|
func addHighlight() {
|
||
|
|
addMarkup(style: .highlight, title: "Highlight Comment")
|
||
|
|
}
|
||
|
|
|
||
|
|
func addUnderline() {
|
||
|
|
addMarkup(style: .underline, title: "Underline Comment")
|
||
|
|
}
|
||
|
|
|
||
|
|
func addComment() {
|
||
|
|
addMarkup(style: .comment, title: "Comment")
|
||
|
|
}
|
||
|
|
|
||
|
|
func addFreeText() {
|
||
|
|
guard document != nil else {
|
||
|
|
statusMessage = "Open a PDF before adding free text."
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
activeEditor = nil
|
||
|
|
placementTool = .freeText
|
||
|
|
statusMessage = "Click on the page to place free text."
|
||
|
|
}
|
||
|
|
|
||
|
|
func placePendingAnnotation(on page: PDFPage, near point: CGPoint) {
|
||
|
|
guard let placementTool else { return }
|
||
|
|
|
||
|
|
let insertion: AnnotationInsertion
|
||
|
|
let title: String
|
||
|
|
|
||
|
|
switch placementTool {
|
||
|
|
case .freeText:
|
||
|
|
insertion = AnnotationFactory.freeTextInsertion(
|
||
|
|
on: page,
|
||
|
|
near: point,
|
||
|
|
text: "",
|
||
|
|
author: AnnotationFactory.defaultAuthor
|
||
|
|
)
|
||
|
|
title = "Free Text"
|
||
|
|
}
|
||
|
|
|
||
|
|
self.placementTool = nil
|
||
|
|
add(insertion)
|
||
|
|
refreshAnnotations()
|
||
|
|
openEditor(
|
||
|
|
title: title,
|
||
|
|
annotations: [insertion.annotation],
|
||
|
|
pages: [page],
|
||
|
|
isNew: true
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
func addReply(to item: AnnotationSnapshot) {
|
||
|
|
let insertion = AnnotationFactory.replyInsertion(
|
||
|
|
to: item.annotation,
|
||
|
|
on: item.page,
|
||
|
|
comment: "",
|
||
|
|
author: AnnotationFactory.defaultAuthor,
|
||
|
|
parentID: item.id
|
||
|
|
)
|
||
|
|
add(insertion)
|
||
|
|
refreshAnnotations()
|
||
|
|
openEditor(
|
||
|
|
title: "Reply",
|
||
|
|
annotations: [insertion.annotation],
|
||
|
|
pages: [item.page],
|
||
|
|
isNew: true
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
func edit(_ item: AnnotationSnapshot) {
|
||
|
|
openEditor(
|
||
|
|
title: item.kind == .freeText ? "Edit Free Text" : "Edit Comment",
|
||
|
|
annotations: [item.annotation],
|
||
|
|
pages: [item.page],
|
||
|
|
isNew: false
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
func delete(_ item: AnnotationSnapshot) {
|
||
|
|
let targets = annotations.filter { candidate in
|
||
|
|
candidate.id == item.id || candidate.parentID == item.id
|
||
|
|
}
|
||
|
|
let targetIDs = Set(targets.map(\.id))
|
||
|
|
|
||
|
|
for target in targets {
|
||
|
|
removeAnnotation(target.annotation, from: target.page)
|
||
|
|
}
|
||
|
|
|
||
|
|
if selectedAnnotationID.map(targetIDs.contains) == true {
|
||
|
|
selectedAnnotationID = nil
|
||
|
|
}
|
||
|
|
if hoveredAnnotationID.map(targetIDs.contains) == true {
|
||
|
|
hoveredAnnotationID = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
activeEditor = nil
|
||
|
|
refreshAnnotations()
|
||
|
|
statusMessage = targets.count > 1 ? "Comment thread deleted." : "Comment deleted."
|
||
|
|
}
|
||
|
|
|
||
|
|
func toggleReviewed(_ item: AnnotationSnapshot) {
|
||
|
|
let isReviewed = ReviewState.isReviewed(item.status)
|
||
|
|
let nextState = isReviewed ? "Unmarked" : "Marked"
|
||
|
|
let date = Date()
|
||
|
|
|
||
|
|
item.annotation.modificationDate = date
|
||
|
|
_ = item.annotation.setValue(date, forAnnotationKey: .date)
|
||
|
|
_ = item.annotation.setValue(nextState, forAnnotationKey: AnnotationKeys.state)
|
||
|
|
_ = item.annotation.setValue("Marked", forAnnotationKey: AnnotationKeys.stateModel)
|
||
|
|
|
||
|
|
if let popup = item.annotation.popup {
|
||
|
|
popup.modificationDate = date
|
||
|
|
}
|
||
|
|
|
||
|
|
pdfView?.annotationsChanged(on: item.page)
|
||
|
|
refreshAnnotations()
|
||
|
|
statusMessage = isReviewed ? "Marked as not reviewed." : "Marked as reviewed."
|
||
|
|
}
|
||
|
|
|
||
|
|
func saveEditor(
|
||
|
|
_ context: AnnotationEditorContext,
|
||
|
|
text: String,
|
||
|
|
author: String
|
||
|
|
) {
|
||
|
|
updateAnnotations(in: context, text: text, author: author)
|
||
|
|
refreshAnnotations()
|
||
|
|
activeEditor = nil
|
||
|
|
statusMessage = "Comment saved."
|
||
|
|
}
|
||
|
|
|
||
|
|
func updateEditorDraft(
|
||
|
|
_ context: AnnotationEditorContext,
|
||
|
|
text: String,
|
||
|
|
author: String
|
||
|
|
) {
|
||
|
|
guard activeEditor?.id == context.id else { return }
|
||
|
|
updateAnnotations(in: context, text: text, author: author)
|
||
|
|
refreshAnnotations()
|
||
|
|
}
|
||
|
|
|
||
|
|
func deleteAnnotations(in context: AnnotationEditorContext) {
|
||
|
|
for (index, annotation) in context.annotations.enumerated() {
|
||
|
|
guard index < context.pages.count else { continue }
|
||
|
|
removeAnnotation(annotation, from: context.pages[index])
|
||
|
|
}
|
||
|
|
|
||
|
|
activeEditor = nil
|
||
|
|
selectedAnnotationID = nil
|
||
|
|
refreshAnnotations()
|
||
|
|
statusMessage = "Annotation deleted."
|
||
|
|
}
|
||
|
|
|
||
|
|
func select(_ item: AnnotationSnapshot) {
|
||
|
|
clearHoveredAnnotation()
|
||
|
|
clearHighlightedAnnotation()
|
||
|
|
selectedAnnotationID = item.id
|
||
|
|
item.annotation.isHighlighted = true
|
||
|
|
pdfView?.go(to: item.bounds.insetBy(dx: -24, dy: -24), on: item.page)
|
||
|
|
pdfView?.annotationsChanged(on: item.page)
|
||
|
|
statusMessage = "\(item.kind.displayName) on page \(item.pageLabel)."
|
||
|
|
}
|
||
|
|
|
||
|
|
func setCommentHover(_ item: AnnotationSnapshot, isHovered: Bool) {
|
||
|
|
if isHovered {
|
||
|
|
clearHoveredAnnotation(except: item.id)
|
||
|
|
hoveredAnnotationID = item.id
|
||
|
|
item.annotation.isHighlighted = true
|
||
|
|
pdfView?.annotationsChanged(on: item.page)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
guard hoveredAnnotationID == item.id else { return }
|
||
|
|
hoveredAnnotationID = nil
|
||
|
|
guard selectedAnnotationID != item.id else { return }
|
||
|
|
item.annotation.isHighlighted = false
|
||
|
|
pdfView?.annotationsChanged(on: item.page)
|
||
|
|
}
|
||
|
|
|
||
|
|
func openAnnotationFromPDF(_ annotation: PDFAnnotation, page: PDFPage) {
|
||
|
|
let parent = AnnotationFactory.parentAnnotation(for: annotation)
|
||
|
|
let targetPage = parent.page ?? page
|
||
|
|
let pageIndex = document?.index(for: targetPage) ?? 0
|
||
|
|
let annotationIndex = targetPage.annotations.firstIndex(where: { $0 === parent }) ?? 0
|
||
|
|
let id = AnnotationKeys.stableID(
|
||
|
|
for: parent,
|
||
|
|
pageIndex: pageIndex,
|
||
|
|
annotationIndex: annotationIndex
|
||
|
|
)
|
||
|
|
if let item = annotations.first(where: { $0.id == id }) {
|
||
|
|
select(item)
|
||
|
|
edit(item)
|
||
|
|
} else {
|
||
|
|
openEditor(
|
||
|
|
title: "Edit Comment",
|
||
|
|
annotations: [parent],
|
||
|
|
pages: [targetPage],
|
||
|
|
isNew: false
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func refreshAnnotations() {
|
||
|
|
guard let document else {
|
||
|
|
annotations = []
|
||
|
|
return
|
||
|
|
}
|
||
|
|
annotations = AnnotationReader.snapshots(in: document)
|
||
|
|
}
|
||
|
|
|
||
|
|
func runSearch() {
|
||
|
|
guard let document else { return }
|
||
|
|
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
|
guard !query.isEmpty else {
|
||
|
|
searchResults = []
|
||
|
|
pdfView?.highlightedSelections = nil
|
||
|
|
statusMessage = "Search cleared."
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let results = document.findString(query, withOptions: [.caseInsensitive, .diacriticInsensitive])
|
||
|
|
for result in results {
|
||
|
|
result.color = NSColor.findHighlightColor.withAlphaComponent(0.45)
|
||
|
|
}
|
||
|
|
searchResults = results
|
||
|
|
pdfView?.highlightedSelections = results
|
||
|
|
currentSearchIndex = 0
|
||
|
|
goToSearchResult(at: currentSearchIndex)
|
||
|
|
statusMessage = results.isEmpty ? "No matches for \(query)." : "\(results.count) search matches."
|
||
|
|
}
|
||
|
|
|
||
|
|
func showSearch() {
|
||
|
|
guard document != nil else { return }
|
||
|
|
showToolbarSearch = true
|
||
|
|
statusMessage = "Search ready."
|
||
|
|
DispatchQueue.main.async { [weak self] in
|
||
|
|
self?.focusToolbarSearchField()
|
||
|
|
}
|
||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||
|
|
self?.focusToolbarSearchField()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func hideSearch() {
|
||
|
|
showToolbarSearch = false
|
||
|
|
if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
|
|
searchResults = []
|
||
|
|
pdfView?.highlightedSelections = nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func nextSearchResult() {
|
||
|
|
guard !searchResults.isEmpty else { return }
|
||
|
|
currentSearchIndex = (currentSearchIndex + 1) % searchResults.count
|
||
|
|
goToSearchResult(at: currentSearchIndex)
|
||
|
|
}
|
||
|
|
|
||
|
|
func previousSearchResult() {
|
||
|
|
guard !searchResults.isEmpty else { return }
|
||
|
|
currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count
|
||
|
|
goToSearchResult(at: currentSearchIndex)
|
||
|
|
}
|
||
|
|
|
||
|
|
func zoomIn() {
|
||
|
|
pdfView?.autoScales = false
|
||
|
|
pdfView?.zoomIn(nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func zoomOut() {
|
||
|
|
pdfView?.autoScales = false
|
||
|
|
pdfView?.zoomOut(nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func fitWidth() {
|
||
|
|
pdfView?.displayMode = .singlePageContinuous
|
||
|
|
pdfView?.autoScales = true
|
||
|
|
statusMessage = "Fit to width."
|
||
|
|
}
|
||
|
|
|
||
|
|
func fitPage() {
|
||
|
|
pdfView?.displayMode = .singlePage
|
||
|
|
pdfView?.autoScales = true
|
||
|
|
statusMessage = "Fit to page."
|
||
|
|
}
|
||
|
|
|
||
|
|
func twoPageContinuous() {
|
||
|
|
pdfView?.displayMode = .twoUpContinuous
|
||
|
|
pdfView?.displayDirection = .vertical
|
||
|
|
pdfView?.displaysAsBook = false
|
||
|
|
pdfView?.autoScales = true
|
||
|
|
statusMessage = "Two pages continuous."
|
||
|
|
}
|
||
|
|
|
||
|
|
func goToPageFromField() {
|
||
|
|
guard let document,
|
||
|
|
let target = Int(pageText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||
|
|
target >= 1,
|
||
|
|
target <= document.pageCount,
|
||
|
|
let page = document.page(at: target - 1)
|
||
|
|
else {
|
||
|
|
updateCurrentPageState()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
navigate(to: page, pageIndex: target - 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func goToPreviousPage() {
|
||
|
|
guard let document, let currentPage = pdfView?.currentPage else { return }
|
||
|
|
let index = document.index(for: currentPage)
|
||
|
|
guard index != NSNotFound, index > 0, let page = document.page(at: index - 1) else {
|
||
|
|
updateCurrentPageState()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
navigate(to: page, pageIndex: index - 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func goToNextPage() {
|
||
|
|
guard let document, let currentPage = pdfView?.currentPage else { return }
|
||
|
|
let index = document.index(for: currentPage)
|
||
|
|
guard index != NSNotFound,
|
||
|
|
index + 1 < document.pageCount,
|
||
|
|
let page = document.page(at: index + 1)
|
||
|
|
else {
|
||
|
|
updateCurrentPageState()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
navigate(to: page, pageIndex: index + 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func toggleFullScreen() {
|
||
|
|
NSApp.keyWindow?.toggleFullScreen(nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func minimizeWindow() {
|
||
|
|
NSApp.keyWindow?.miniaturize(nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func addMarkup(style: MarkupAnnotationStyle, title: String) {
|
||
|
|
guard let selection = pdfView?.currentSelection, !selection.pages.isEmpty else {
|
||
|
|
statusMessage = style == .comment
|
||
|
|
? "Select text before adding a comment."
|
||
|
|
: "Select text before adding a markup annotation."
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let insertions = AnnotationFactory.markupInsertions(
|
||
|
|
from: selection,
|
||
|
|
style: style,
|
||
|
|
comment: "",
|
||
|
|
author: AnnotationFactory.defaultAuthor
|
||
|
|
)
|
||
|
|
guard !insertions.isEmpty else {
|
||
|
|
statusMessage = "No selectable text was found in the selection."
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
for insertion in insertions {
|
||
|
|
add(insertion)
|
||
|
|
}
|
||
|
|
pdfView?.clearSelection()
|
||
|
|
refreshAnnotations()
|
||
|
|
openEditor(
|
||
|
|
title: title,
|
||
|
|
annotations: insertions.map(\.annotation),
|
||
|
|
pages: insertions.map(\.page),
|
||
|
|
isNew: true
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func add(_ insertion: AnnotationInsertion) {
|
||
|
|
insertion.page.addAnnotation(insertion.annotation)
|
||
|
|
if let popup = insertion.popup {
|
||
|
|
insertion.page.addAnnotation(popup)
|
||
|
|
}
|
||
|
|
pdfView?.annotationsChanged(on: insertion.page)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func updateAnnotations(
|
||
|
|
in context: AnnotationEditorContext,
|
||
|
|
text: String,
|
||
|
|
author: String
|
||
|
|
) {
|
||
|
|
let trimmedAuthor = author.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
|
let authorValue = trimmedAuthor.isEmpty ? AnnotationFactory.defaultAuthor : trimmedAuthor
|
||
|
|
|
||
|
|
for (index, annotation) in context.annotations.enumerated() {
|
||
|
|
guard index < context.pages.count else { continue }
|
||
|
|
let page = context.pages[index]
|
||
|
|
let popup = AnnotationFactory.updateComment(
|
||
|
|
for: annotation,
|
||
|
|
on: page,
|
||
|
|
text: text,
|
||
|
|
author: authorValue
|
||
|
|
)
|
||
|
|
if let popup {
|
||
|
|
page.addAnnotation(popup)
|
||
|
|
}
|
||
|
|
pdfView?.annotationsChanged(on: page)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func removeAnnotation(_ annotation: PDFAnnotation, from page: PDFPage) {
|
||
|
|
let linkedPopups = page.annotations.filter { candidate in
|
||
|
|
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
|
||
|
|
return candidate === annotation.popup || AnnotationFactory.parentAnnotation(for: candidate) === annotation
|
||
|
|
}
|
||
|
|
|
||
|
|
for popup in linkedPopups {
|
||
|
|
page.removeAnnotation(popup)
|
||
|
|
}
|
||
|
|
if let popup = annotation.popup, popup.page != nil {
|
||
|
|
page.removeAnnotation(popup)
|
||
|
|
}
|
||
|
|
|
||
|
|
page.removeAnnotation(annotation)
|
||
|
|
pdfView?.annotationsChanged(on: page)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func openEditor(
|
||
|
|
title: String,
|
||
|
|
annotations: [PDFAnnotation],
|
||
|
|
pages: [PDFPage],
|
||
|
|
isNew: Bool
|
||
|
|
) {
|
||
|
|
closeNativePopups(on: pages)
|
||
|
|
let first = annotations.first
|
||
|
|
activeEditor = AnnotationEditorContext(
|
||
|
|
title: title,
|
||
|
|
annotations: annotations,
|
||
|
|
pages: pages,
|
||
|
|
isNewAnnotation: isNew,
|
||
|
|
allowsDelete: true,
|
||
|
|
initialText: first?.contents ?? "",
|
||
|
|
initialAuthor: first?.userName ?? AnnotationFactory.defaultAuthor
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func closeNativePopups(on pages: [PDFPage]) {
|
||
|
|
for page in pages {
|
||
|
|
for annotation in page.annotations {
|
||
|
|
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
|
||
|
|
annotation.isOpen = false
|
||
|
|
}
|
||
|
|
annotation.popup?.isOpen = false
|
||
|
|
}
|
||
|
|
pdfView?.annotationsChanged(on: page)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func configure(_ view: PDFView) {
|
||
|
|
view.displayMode = .singlePageContinuous
|
||
|
|
view.displayDirection = .vertical
|
||
|
|
view.displaysPageBreaks = true
|
||
|
|
view.pageBreakMargins = NSEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
|
||
|
|
view.displayBox = .cropBox
|
||
|
|
view.autoScales = true
|
||
|
|
view.minScaleFactor = 0.25
|
||
|
|
view.maxScaleFactor = 6
|
||
|
|
view.interpolationQuality = .high
|
||
|
|
view.backgroundColor = NSColor.controlBackgroundColor
|
||
|
|
view.acceptsDraggedFiles = true
|
||
|
|
view.pageShadowsEnabled = true
|
||
|
|
}
|
||
|
|
|
||
|
|
private func write(_ document: PDFDocument, to url: URL) {
|
||
|
|
guard document.write(to: url) else {
|
||
|
|
showAlert(title: "Save Failed", message: "The PDF could not be written to \(url.path).")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
refreshAnnotations()
|
||
|
|
statusMessage = "Saved \(url.lastPathComponent)."
|
||
|
|
}
|
||
|
|
|
||
|
|
private func presentSharePicker(for url: URL) {
|
||
|
|
guard let contentView = NSApp.keyWindow?.contentView else { return }
|
||
|
|
|
||
|
|
let anchor = NSRect(
|
||
|
|
x: contentView.bounds.maxX - 24,
|
||
|
|
y: contentView.bounds.maxY - 24,
|
||
|
|
width: 1,
|
||
|
|
height: 1
|
||
|
|
)
|
||
|
|
let picker = NSSharingServicePicker(items: [url])
|
||
|
|
picker.show(relativeTo: anchor, of: contentView, preferredEdge: .minY)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func suggestedAnnotatedFilename() -> String {
|
||
|
|
guard let url = documentURL else { return "Annotated.pdf" }
|
||
|
|
let base = url.deletingPathExtension().lastPathComponent
|
||
|
|
return "\(base)-annotated.pdf"
|
||
|
|
}
|
||
|
|
|
||
|
|
private func goToSearchResult(at index: Int) {
|
||
|
|
guard searchResults.indices.contains(index), let pdfView else { return }
|
||
|
|
let selection = searchResults[index]
|
||
|
|
pdfView.setCurrentSelection(selection, animate: true)
|
||
|
|
pdfView.go(to: selection)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func navigate(to page: PDFPage, pageIndex: Int) {
|
||
|
|
guard let pdfView else { return }
|
||
|
|
|
||
|
|
let bounds = page.bounds(for: pdfView.displayBox)
|
||
|
|
let topSlice = NSRect(
|
||
|
|
x: bounds.minX,
|
||
|
|
y: bounds.maxY - 1,
|
||
|
|
width: bounds.width,
|
||
|
|
height: 1
|
||
|
|
)
|
||
|
|
pdfView.go(to: topSlice, on: page)
|
||
|
|
pdfView.setNeedsDisplay(pdfView.bounds)
|
||
|
|
|
||
|
|
currentPageIndex = pageIndex
|
||
|
|
pageText = "\(pageIndex + 1)"
|
||
|
|
}
|
||
|
|
|
||
|
|
private func updateCurrentPageState() {
|
||
|
|
guard let document, let currentPage = pdfView?.currentPage else { return }
|
||
|
|
let index = document.index(for: currentPage)
|
||
|
|
guard index != NSNotFound else { return }
|
||
|
|
currentPageIndex = index
|
||
|
|
pageText = "\(index + 1)"
|
||
|
|
}
|
||
|
|
|
||
|
|
private func clearHighlightedAnnotation() {
|
||
|
|
guard let selectedAnnotationID,
|
||
|
|
let previous = annotations.first(where: { $0.id == selectedAnnotationID })
|
||
|
|
else {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
previous.annotation.isHighlighted = false
|
||
|
|
pdfView?.annotationsChanged(on: previous.page)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func clearHoveredAnnotation(except keptID: String? = nil) {
|
||
|
|
guard let hoveredAnnotationID,
|
||
|
|
hoveredAnnotationID != keptID,
|
||
|
|
hoveredAnnotationID != selectedAnnotationID,
|
||
|
|
let previous = annotations.first(where: { $0.id == hoveredAnnotationID })
|
||
|
|
else {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
previous.annotation.isHighlighted = false
|
||
|
|
pdfView?.annotationsChanged(on: previous.page)
|
||
|
|
self.hoveredAnnotationID = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
private func showAlert(title: String, message: String) {
|
||
|
|
let alert = NSAlert()
|
||
|
|
alert.alertStyle = .warning
|
||
|
|
alert.messageText = title
|
||
|
|
alert.informativeText = message
|
||
|
|
alert.addButton(withTitle: "OK")
|
||
|
|
alert.runModal()
|
||
|
|
}
|
||
|
|
|
||
|
|
private func focusToolbarSearchField() {
|
||
|
|
guard let window = NSApp.keyWindow,
|
||
|
|
let root = window.contentView?.superview,
|
||
|
|
let field = findSearchField(in: root)
|
||
|
|
else {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
window.makeFirstResponder(field)
|
||
|
|
field.selectText(nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func findSearchField(in view: NSView) -> NSTextField? {
|
||
|
|
if let field = view as? NSTextField,
|
||
|
|
field.placeholderString == "Search" {
|
||
|
|
return field
|
||
|
|
}
|
||
|
|
|
||
|
|
for subview in view.subviews {
|
||
|
|
if let field = findSearchField(in: subview) {
|
||
|
|
return field
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
private func applySidebarPreferenceForCurrentDocument() {
|
||
|
|
guard let documentURL else {
|
||
|
|
applySidebarPreference(.defaultReading)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let preference = AppDefaults.sidebarPreference(
|
||
|
|
for: sidebarPreferenceKey(for: documentURL, bucket: sidebarWidthBucket)
|
||
|
|
) ?? .defaultReading
|
||
|
|
applySidebarPreference(preference)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func applySidebarPreference(_ preference: SidebarPreference) {
|
||
|
|
isApplyingSidebarPreference = true
|
||
|
|
showLeftSidebar = preference.showLeftSidebar
|
||
|
|
showCommentsSidebar = preference.showCommentsSidebar
|
||
|
|
isApplyingSidebarPreference = false
|
||
|
|
}
|
||
|
|
|
||
|
|
private func persistSidebarPreferenceIfNeeded() {
|
||
|
|
guard !isApplyingSidebarPreference,
|
||
|
|
let documentURL
|
||
|
|
else {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let preference = SidebarPreference(
|
||
|
|
showLeftSidebar: showLeftSidebar,
|
||
|
|
showCommentsSidebar: showCommentsSidebar
|
||
|
|
)
|
||
|
|
AppDefaults.setSidebarPreference(
|
||
|
|
preference,
|
||
|
|
for: sidebarPreferenceKey(for: documentURL, bucket: sidebarWidthBucket)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func sidebarPreferenceKey(for url: URL, bucket: SidebarWidthBucket) -> String {
|
||
|
|
let documentKey = url.isFileURL ? url.standardizedFileURL.path : url.absoluteString
|
||
|
|
return "\(documentKey)#\(bucket.rawValue)"
|
||
|
|
}
|
||
|
|
}
|