import AppKit import Foundation import IHatePDFsCore import PDFKit import SwiftUI import UniformTypeIdentifiers enum SidebarMode: String, CaseIterable { case pages case annotations case highlights } enum LeftSidebarMode: String, CaseIterable, Codable { case pages case annotations } enum CommentFilter: String, CaseIterable { case all case withComments case withoutComments var title: String { switch self { case .all: return "All" case .withComments: return "With Comments" case .withoutComments: return "No Comment" } } } enum HighlightSortMode: String, CaseIterable { case color case page var title: String { switch self { case .color: return "Color" case .page: return "Page" } } var systemImage: String { switch self { case .color: return "paintpalette" case .page: return "doc.text" } } } enum AnnotationPlacementTool: Equatable { case freeText var cancellationMessage: String { switch self { case .freeText: return "Free text placement canceled." } } } private enum AppDefaults { static let documentPageProgress = "IHatePDFs.documentPageProgress.v1" static let documentBookmarks = "IHatePDFs.documentBookmarks.v1" static func pageProgress(for url: URL) -> PDFRecentDocumentProgress? { PDFRecentDocuments.progress(for: url, in: pageProgressRecords()) } static func setPageProgress(url: URL, pageIndex: Int) { guard let progress = PDFRecentDocuments.updatedProgress( pageProgressRecords(), url: url, pageIndex: pageIndex, openedAt: Date() )[PDFRecentDocuments.documentKey(for: url)] else { return } var records = UserDefaults.standard.dictionary(forKey: documentPageProgress) ?? [:] records[progress.key] = [ "pageIndex": progress.pageIndex, "openedAt": progress.openedAt.timeIntervalSince1970 ] UserDefaults.standard.set(records, forKey: documentPageProgress) } private static func pageProgressRecords() -> [String: PDFRecentDocumentProgress] { let raw = UserDefaults.standard.dictionary(forKey: documentPageProgress) ?? [:] var records: [String: PDFRecentDocumentProgress] = [:] for (key, value) in raw { guard let dictionary = value as? [String: Any], let pageIndex = dictionary["pageIndex"] as? Int, let openedAt = dictionary["openedAt"] as? TimeInterval else { continue } records[key] = PDFRecentDocumentProgress( key: key, pageIndex: pageIndex, openedAt: Date(timeIntervalSince1970: openedAt) ) } return records } static func bookmarks(for url: URL) -> [PDFDocumentBookmark] { bookmarkRecords()[PDFRecentDocuments.documentKey(for: url)] ?? [] } static func setBookmarks(_ bookmarks: [PDFDocumentBookmark], for url: URL) { var records = UserDefaults.standard.dictionary(forKey: documentBookmarks) ?? [:] records[PDFRecentDocuments.documentKey(for: url)] = bookmarks.map { bookmark in [ "id": bookmark.id, "pageIndex": bookmark.pageIndex, "pageLabel": bookmark.pageLabel, "title": bookmark.title, "createdAt": bookmark.createdAt.timeIntervalSince1970 ] as [String: Any] } UserDefaults.standard.set(records, forKey: documentBookmarks) } private static func bookmarkRecords() -> [String: [PDFDocumentBookmark]] { let raw = UserDefaults.standard.dictionary(forKey: documentBookmarks) ?? [:] var records: [String: [PDFDocumentBookmark]] = [:] for (key, value) in raw { guard let dictionaries = value as? [[String: Any]] else { continue } records[key] = dictionaries.compactMap { dictionary in guard let id = dictionary["id"] as? String, let pageIndex = dictionary["pageIndex"] as? Int, let pageLabel = dictionary["pageLabel"] as? String, let title = dictionary["title"] as? String, let createdAt = dictionary["createdAt"] as? TimeInterval else { return nil } return PDFDocumentBookmark( id: id, pageIndex: pageIndex, pageLabel: pageLabel, title: title, createdAt: Date(timeIntervalSince1970: createdAt) ) } } return records } } private enum PDFReadingLayout { static let pageBreakMargins = NSEdgeInsets(top: 10, left: 11, bottom: 10, right: 11) static let minimumScaleFactor: CGFloat = 0.25 static let maximumReadingScaleFactor: CGFloat = 4 } struct AnnotationEditorContext: Identifiable { let id = UUID() let title: String let annotations: [PDFAnnotation] let pages: [PDFPage] let isNewAnnotation: Bool let hadUnsavedChangesBeforeCreation: Bool let allowsDelete: Bool let allowsReply: Bool let initialText: String let initialAuthor: String var primaryAnnotation: PDFAnnotation? { annotations.first } var primaryPage: PDFPage? { pages.first } } struct HighlightedTextGroup { let id: String let title: String let color: Color let items: [AnnotationSnapshot] } struct RecentDocumentItem: Identifiable, Equatable { let url: URL let pageIndex: Int? let openedAt: Date? var id: URL { url } var title: String { url.deletingPathExtension().lastPathComponent } var folderName: String { let parent = url.deletingLastPathComponent() let name = parent.lastPathComponent return name.isEmpty ? parent.path : name } var pageText: String? { pageIndex.map { "Page \($0 + 1)" } } } @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 { clearSelectedAnnotationIfHiddenBySidebarState() } } @Published var leftSidebarMode: LeftSidebarMode = .pages { didSet { clearSelectedAnnotationIfHiddenBySidebarState() } } @Published var showCommentsSidebar = false { didSet { if showCommentsSidebar, sidebarMode == .pages { sidebarMode = .annotations } if !showCommentsSidebar { clearHoveredAnnotation() } clearSelectedAnnotationIfHiddenBySidebarState() } } @Published var sidebarMode: SidebarMode = .annotations { didSet { clearSelectedAnnotationIfHiddenBySidebarState() } } @Published var searchText = "" { didSet { clearSearchResultsForEditedQuery() } } @Published var showToolbarSearch = false @Published var searchResults: [PDFSelection] = [] @Published private(set) var toolbarSearchFocusRequest = 0 @Published var hasTextSelection = false @Published var isHighlighterModeActive = false @Published var currentSearchIndex = 0 @Published var pageText = "1" @Published var currentPageIndex = 0 @Published var commentSearchText = "" { didSet { clearCommentReviewHighlightsHiddenBySidebarVisibility() } } @Published var commentFilter: CommentFilter = .all { didSet { clearCommentReviewHighlightsHiddenBySidebarVisibility() } } @Published var selectedKindFilter: AcademicAnnotationKind? { didSet { clearCommentReviewHighlightsHiddenBySidebarVisibility() } } @Published var selectedAuthorFilter = "All Authors" { didSet { clearCommentReviewHighlightsHiddenBySidebarVisibility() } } @Published var selectedStatusFilter = ReviewState.allStatuses { didSet { clearCommentReviewHighlightsHiddenBySidebarVisibility() } } @Published var highlightSortMode: HighlightSortMode = .color @Published var collapsedPageIndexes: Set = [] { didSet { clearCommentReviewHighlightsHiddenBySidebarVisibility() } } @Published var sidebarReplyParentID: String? @Published var sidebarReplyTargetID: String? @Published var sidebarReplyDraft = "" @Published var sidebarReplyAuthor = AnnotationFactory.defaultAuthor @Published var bookmarks: [PDFDocumentBookmark] = [] @Published var recentDocumentURLs: [URL] = [] @Published private(set) var readerSizeClass: ReaderAdaptiveLayout.SizeClass = .regular @Published var hasUnsavedChanges = false @Published var statusMessage = "Open a PDF to begin." private var pageObserver: NSObjectProtocol? private var selectionObserver: NSObjectProtocol? private var hoveredAnnotationID: String? private var activeSearchQuery: String? private var pendingInitialPageIndex: Int? weak var hostingWindow: NSWindow? override init() { super.init() refreshRecentDocuments() } deinit { MainActor.assumeIsolated { removePDFViewObservers() } } var displayTitle: String { documentURL?.lastPathComponent ?? "I Hate PDFs" } var pageCount: Int { document?.pageCount ?? 0 } var canGoToPreviousPage: Bool { document != nil && currentPageIndex > 0 } var canGoToNextPage: Bool { document != nil && currentPageIndex + 1 < pageCount } var hasUnsavedWork: Bool { hasUnsavedChanges || hasSidebarReplyDraft } var hasUnsentSidebarReplyDraft: Bool { hasSidebarReplyDraft } var isCommentsReviewVisible: Bool { showCommentsSidebar && sidebarMode == .annotations } var isCompactWindow: Bool { readerSizeClass == .compact } var canShowSelectionActions: Bool { document != nil && hasTextSelection && placementTool == nil && activeEditor == nil && !isHighlighterModeActive } var canClearSearchQuery: Bool { !searchText.isEmpty || !searchResults.isEmpty } var searchSummaryText: String? { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { return nil } if searchResults.isEmpty { return activeSearchQuery == query ? "No match" : nil } let displayIndex = min(max(currentSearchIndex, 0), searchResults.count - 1) + 1 return "\(displayIndex)/\(searchResults.count)" } var canSaveDocument: Bool { document != nil && hasUnsavedWork } var activePlacementName: String? { switch placementTool { case .freeText: return "Free Text" case nil: return nil } } var saveHelpText: String { guard document != nil else { return "Open a PDF before saving." } if hasUnsavedChanges { return "Save PDF" } if hasSidebarReplyDraft { return "Send or cancel the reply draft before saving." } return "No unsaved changes." } 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 currentPageBookmark: PDFDocumentBookmark? { PDFDocumentBookmarks.bookmark(on: currentPageIndex, in: bookmarks) } var savedBookmark: PDFDocumentBookmark? { bookmarks.first } var bookmarkActionTitle: String { if currentPageBookmark != nil { return "Remove Bookmark" } if savedBookmark != nil { return "Move Bookmark" } return "Add Bookmark" } var bookmarkActionHelpText: String { if currentPageBookmark != nil { return "Remove Bookmark" } if savedBookmark != nil { return "Move Bookmark to Current Page" } return "Bookmark Current Page" } var highlightedTextGroups: [HighlightedTextGroup] { let grouped = Dictionary(grouping: highlightedTextItems) { highlightColorKey(for: $0) } return grouped .map { key, items in HighlightedTextGroup( id: key, title: highlightColorTitle(for: key), color: Color(nsColor: AppSettings.displayColor(forHighlightColor: highlightColor(for: key))), items: AnnotationReader.sorted(items) ) } .sorted { left, right in if left.title != right.title { return left.title < right.title } return left.id < right.id } } var highlightedTextItems: [AnnotationSnapshot] { AnnotationReader.sorted(annotations.filter { $0.kind == .highlight }) } var recentDocuments: [RecentDocumentItem] { recentDocumentURLs.map { url in let progress = AppDefaults.pageProgress(for: url) return RecentDocumentItem( url: url, pageIndex: progress?.pageIndex, openedAt: progress?.openedAt ) } } var filteredAnnotations: [AnnotationSnapshot] { let query = commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines) return annotations.filter { item in guard isCommentReviewItem(item) else { return false } 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 } 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] { let filtered = filteredAnnotations let matchingTopLevelIDs = Set(filtered.filter { !$0.isReply }.map(\.id)) let matchingReplyParentIDs = Set(filtered.compactMap { item in item.isReply ? item.parentID : nil }) return annotations.filter { item in !item.isReply && (matchingTopLevelIDs.contains(item.id) || matchingReplyParentIDs.contains(item.id)) } } var repliesByParent: [String: [AnnotationSnapshot]] { Dictionary( grouping: filteredAnnotations.filter { $0.isReply && $0.hasComment }, by: \.parentID! ) } var sidebarReplyTarget: AnnotationSnapshot? { guard let sidebarReplyTargetID else { return nil } return annotations.first { $0.id == sidebarReplyTargetID } } func clearCommentFilters() { commentSearchText = "" commentFilter = .all selectedKindFilter = nil selectedAuthorFilter = "All Authors" selectedStatusFilter = ReviewState.allStatuses statusMessage = "Comment filters cleared." } private func resetCommentReviewState() { commentSearchText = "" commentFilter = .all selectedKindFilter = nil selectedAuthorFilter = "All Authors" selectedStatusFilter = ReviewState.allStatuses collapsedPageIndexes = [] } func attachPDFView(_ view: PDFView) { if pdfView === view { return } removePDFViewObservers() pdfView = view configure(view) view.document = document if let pendingInitialPageIndex, let document { goToInitialPage(pendingInitialPageIndex, in: document) fitOpenedDocumentToScreen() animateDocumentViewIn() } 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 let self else { return } self.updateTextSelectionState() guard !self.isHighlighterModeActive else { return } guard self.placementTool == nil, self.hasTextSelection else { return } self.statusMessage = "Selection ready for annotation." } } updateTextSelectionState() } private func removePDFViewObservers() { if let pageObserver { NotificationCenter.default.removeObserver(pageObserver) self.pageObserver = nil } if let selectionObserver { NotificationCenter.default.removeObserver(selectionObserver) self.selectionObserver = nil } } func updateWindowWidth(_ width: CGFloat) { guard width.isFinite, width > 0 else { return } let sizeClass = ReaderAdaptiveLayout.SizeClass(width: width) guard sizeClass != readerSizeClass else { return } readerSizeClass = sizeClass enforceCompactSidebarRules() } 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) } @discardableResult func openDroppedDocument(from providers: [NSItemProvider]) -> Bool { guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) }) else { statusMessage = "Drop a PDF file to open it." return false } provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { [weak self] item, error in let url = Self.fileURL(fromDroppedItem: item) Task { @MainActor in guard let self else { return } if error != nil { self.statusMessage = "The dropped file could not be opened." return } guard let url else { self.statusMessage = "Drop a PDF file to open it." return } guard PDFFileSelection.isPDFFileURL(url) else { self.showAlert(title: "Unsupported File", message: "Drop a PDF file to open it.") return } self.loadDocument(from: url) } } return true } nonisolated private static func fileURL(fromDroppedItem item: NSSecureCoding?) -> URL? { if let url = item as? URL, url.isFileURL { return url } if let url = item as? NSURL { let bridgedURL = url as URL return bridgedURL.isFileURL ? bridgedURL : nil } if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil), url.isFileURL { return url } if let string = item as? String, let url = URL(string: string), url.isFileURL { return url } return nil } func loadDocument( from url: URL, checkingUnsavedChanges: Bool = true ) { if checkingUnsavedChanges { guard confirmDiscardOrSaveUnsavedChanges(actionName: "opening another PDF") else { return } } 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 } resetToFocusedReadingLayout() document = pdf documentURL = url NSDocumentController.shared.noteNewRecentDocumentURL(url) refreshRecentDocuments() refreshBookmarks(for: url, pageCount: pdf.pageCount) let restoredPageIndex = PDFRecentDocuments.clampedPageIndex( AppDefaults.pageProgress(for: url)?.pageIndex, pageCount: pdf.pageCount ) prepareDocumentViewForOpenAnimation() pdfView?.document = pdf goToInitialPage(restoredPageIndex, in: pdf) fitOpenedDocumentToScreen() clearSearchState() resetCommentReviewState() selectedAnnotationID = nil activeEditor = nil placementTool = nil isHighlighterModeActive = false hasTextSelection = false hasUnsavedChanges = false clearSidebarReplyDraft() refreshAnnotations() animateDocumentViewIn() statusMessage = "Opened \(url.lastPathComponent)." } func refreshRecentDocuments() { recentDocumentURLs = PDFRecentDocuments.filteredPDFs( from: NSDocumentController.shared.recentDocumentURLs, currentURL: documentURL, limit: 10 ) } func openRecentDocument(_ url: URL) { guard PDFFileSelection.isPDFFileURL(url) else { showAlert(title: "Unsupported File", message: "Choose a PDF file.") refreshRecentDocuments() return } guard FileManager.default.fileExists(atPath: url.path) else { showAlert(title: "File Not Found", message: "\(url.lastPathComponent) is no longer available.") refreshRecentDocuments() return } loadDocument(from: url) } func clearRecentDocuments() { NSDocumentController.shared.clearRecentDocuments(nil) refreshRecentDocuments() statusMessage = "Recent PDFs cleared." } func addBookmark() { guard let document, let documentURL else { statusMessage = "Open a PDF before adding a bookmark." return } let isMovingExistingBookmark = savedBookmark != nil let pageLabel = document.page(at: currentPageIndex)?.label ?? "\(currentPageIndex + 1)" let bookmark = PDFDocumentBookmark( pageIndex: currentPageIndex, pageLabel: pageLabel, title: "Page \(pageLabel)" ) bookmarks = PDFDocumentBookmarks.upsert(bookmark, in: bookmarks) AppDefaults.setBookmarks(bookmarks, for: documentURL) statusMessage = isMovingExistingBookmark ? "Moved bookmark to page \(pageLabel)." : "Bookmarked page \(pageLabel)." } func removeBookmark(_ bookmark: PDFDocumentBookmark) { guard let documentURL else { return } bookmarks = PDFDocumentBookmarks.removing(id: bookmark.id, from: bookmarks) AppDefaults.setBookmarks(bookmarks, for: documentURL) statusMessage = "Bookmark removed." } func toggleBookmarkForCurrentPage() { if let bookmark = currentPageBookmark { removeBookmark(bookmark) } else { addBookmark() } } func togglePageSidebar() { if showLeftSidebar { showLeftSidebar = false return } if isCompactWindow { showCommentsSidebar = false } leftSidebarMode = .pages showLeftSidebar = true } func toggleAnnotationSidebar() { if showLeftSidebar && leftSidebarMode == .annotations { showLeftSidebar = false return } if isCompactWindow { showCommentsSidebar = false } leftSidebarMode = .annotations showLeftSidebar = true } func toggleLeftSidebar(mode: SidebarMode) { guard mode != .pages else { togglePageSidebar() return } toggleRightSidebar(mode: mode) } func toggleRightSidebar(mode: SidebarMode = .annotations) { let targetMode = mode == .pages ? .annotations : mode if showCommentsSidebar { hideRightSidebar() return } if isCompactWindow { showLeftSidebar = false } sidebarMode = targetMode showCommentsSidebar = true } func toggleRightSidebarVisibility() { if showCommentsSidebar { hideRightSidebar() return } showRightSidebar(mode: sidebarMode) } func toggleCommentsReview() { toggleRightSidebarVisibility() } func hideRightSidebar() { showCommentsSidebar = false if !ReaderAdaptiveLayout(sizeClass: readerSizeClass).allowsDualSidebars { showLeftSidebar = false } } func showRightSidebar(mode: SidebarMode = .annotations) { if isCompactWindow { showLeftSidebar = false } sidebarMode = mode == .pages ? .annotations : mode showCommentsSidebar = true } func selectHighlightColor(_ color: NSColor, applyToSelection: Bool) { AppSettings.highlightColor = color isHighlighterModeActive = true if applyToSelection { addHighlight() } else { statusMessage = "Highlighter on. Select text to highlight." } } func goToBookmark(_ bookmark: PDFDocumentBookmark) { guard let document, let page = document.page(at: bookmark.pageIndex) else { statusMessage = "Bookmark page is unavailable." return } navigate(to: page, pageIndex: bookmark.pageIndex) statusMessage = "Bookmark: \(bookmark.title)." } func goToSavedBookmark() { guard let bookmark = savedBookmark else { statusMessage = "No bookmarks." return } goToBookmark(bookmark) } func confirmDocumentWindowClose() -> Bool { guard confirmDiscardOrSaveUnsavedChanges(actionName: "closing this window") else { return false } persistCurrentPageProgress() return true } func confirmApplicationQuit() -> Bool { guard confirmDiscardOrSaveUnsavedChanges(actionName: "quitting the app") else { return false } persistCurrentPageProgress() return true } func closeDocument() { guard document != nil else { statusMessage = "No PDF is open." return } guard confirmDiscardOrSaveUnsavedChanges(actionName: "closing this PDF") else { return } persistCurrentPageProgress() clearOpenDocumentState() refreshRecentDocuments() statusMessage = "Closed PDF." } func saveDocument() { _ = saveDocument(confirmOverwrite: true, confirmReplyDraft: true) } @discardableResult private func saveDocument(confirmOverwrite: Bool, confirmReplyDraft: Bool) -> Bool { guard let document else { return false } let discardedEmptyEditor = discardEmptyActiveEditorBeforeWritingIfNeeded() if confirmReplyDraft { guard confirmSaveWithoutSidebarReplyDraft() else { return false } } if let url = documentURL { guard hasUnsavedChanges else { if !discardedEmptyEditor { statusMessage = "No unsaved changes." } return true } if confirmOverwrite { 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 false } } return write(document, to: url) } else { return saveDocumentAs(confirmReplyDraft: confirmReplyDraft) } } @discardableResult func saveDocumentAs() -> Bool { saveDocumentAs(confirmReplyDraft: true) } @discardableResult private func saveDocumentAs(confirmReplyDraft: Bool) -> Bool { guard let document else { return false } _ = discardEmptyActiveEditorBeforeWritingIfNeeded() if confirmReplyDraft { guard confirmSaveWithoutSidebarReplyDraft() else { return false } } 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 false } guard write(document, to: url) else { return false } documentURL = url return true } func shareDocument() { guard let document else { return } guard confirmShareWithoutSidebarReplyDraft() else { return } _ = discardEmptyActiveEditorBeforeWritingIfNeeded() var shareURL = documentURL if shareURL == nil { guard saveDocumentAs(confirmReplyDraft: false), let url = documentURL else { return } shareURL = url } guard let url = shareURL else { return } guard hasUnsavedChanges else { presentSharePicker(for: url) statusMessage = "Ready to share \(url.lastPathComponent)." return } let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Save Before Sharing?" alert.informativeText = "This PDF has unsaved annotations. Save them to \(url.lastPathComponent) before sharing, or share the last saved version without the latest changes." alert.addButton(withTitle: "Save and Share") alert.addButton(withTitle: "Share Last Saved Version") alert.addButton(withTitle: "Cancel") switch alert.runModal() { case .alertFirstButtonReturn: guard write(document, to: url) else { return } case .alertSecondButtonReturn: statusMessage = "Sharing last saved version; unsaved annotations remain open." break default: return } presentSharePicker(for: url) statusMessage = "Ready to share \(url.lastPathComponent)." } func addHighlight() { addMarkup(style: .highlight, title: "Highlight", opensEditor: false) } func addHighlightFromHighlighterMode() { addMarkup(style: .highlight, title: "Highlight", opensEditor: false) } func toggleHighlighterMode() { guard document != nil else { statusMessage = "Open a PDF before highlighting." return } isHighlighterModeActive.toggle() guard isHighlighterModeActive else { statusMessage = "Highlighter off." return } if hasTextSelection { addHighlightFromHighlighterMode() } else { pdfView?.window?.makeFirstResponder(pdfView) statusMessage = "Highlighter on. Select text to highlight." } } func addUnderline() { addMarkup(style: .underline, title: "Underline Comment", opensEditor: true) } func addComment() { addMarkup(style: .comment, title: "Comment", opensEditor: true) } func addFreeText() { guard document != nil else { statusMessage = "Open a PDF before adding free text." return } activeEditor = nil placementTool = .freeText pdfView?.window?.makeFirstResponder(pdfView) statusMessage = "Click on the page to place free text." } func cancelPlacementTool() { guard let placementTool else { return } self.placementTool = nil statusMessage = placementTool.cancellationMessage } func placePendingAnnotation(on page: PDFPage, near point: CGPoint) { guard let placementTool else { return } let insertion: AnnotationInsertion let hadUnsavedChangesBeforeCreation = hasUnsavedChanges switch placementTool { case .freeText: insertion = AnnotationFactory.freeTextInsertion( on: page, near: point, text: "", author: AnnotationFactory.defaultAuthor ) } self.placementTool = nil add(insertion) refreshAnnotations(on: [page]) openEditor( title: "Free Text", annotations: [insertion.annotation], pages: [page], isNew: true, hadUnsavedChangesBeforeCreation: hadUnsavedChangesBeforeCreation ) } func addReply(to item: AnnotationSnapshot) { beginSidebarReply(to: item) } func beginSidebarReply( to target: AnnotationSnapshot, inThread threadRoot: AnnotationSnapshot? = nil ) { guard let root = threadRoot ?? rootComment(for: target) else { statusMessage = "Original comment no longer exists." return } if hasSidebarReplyDraft { guard sidebarReplyParentID == root.id, sidebarReplyTargetID == target.id else { showRightSidebar() statusMessage = "Finish or cancel the current reply before starting another." return } showRightSidebar() select(target, statusMessage: "Reply draft is already open.") return } activeEditor = nil showRightSidebar() sidebarReplyParentID = root.id sidebarReplyTargetID = target.id sidebarReplyDraft = "" sidebarReplyAuthor = AnnotationFactory.defaultAuthor select(target, statusMessage: "Replying to \(target.author).") } func cancelSidebarReply() { clearSidebarReplyDraft() statusMessage = "Reply canceled." } func commitSidebarReply() { let trimmedText = sidebarReplyDraft.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedText.isEmpty else { statusMessage = "Type a reply before saving." return } guard let sidebarReplyParentID, let parent = annotations.first(where: { $0.id == sidebarReplyParentID }) else { clearSidebarReplyDraft() statusMessage = "Original comment no longer exists." return } let author = sidebarReplyAuthor.trimmingCharacters(in: .whitespacesAndNewlines) let insertion = AnnotationFactory.replyInsertion( to: parent.annotation, on: parent.page, comment: trimmedText, author: author.isEmpty ? AnnotationFactory.defaultAuthor : author, parentID: parent.id ) add(insertion) clearSidebarReplyDraft() refreshAnnotations(on: [parent.page]) if let reply = annotations.first(where: { $0.annotation === insertion.annotation }) { selectedAnnotationID = reply.id } statusMessage = "Reply added." } func replyFromEditor( _ context: AnnotationEditorContext, text: String, author: String ) { updateAnnotations(in: context, text: text, author: author) refreshAnnotations(on: context.pages) guard let annotation = context.primaryAnnotation, let item = snapshot(for: annotation) else { activeEditor = nil showRightSidebar() statusMessage = "Comment saved." return } activeEditor = nil beginSidebarReply(to: item) } func edit(_ item: AnnotationSnapshot) { let editorTitle = item.kind == .freeText ? "Edit Free Text" : "Edit Comment" select(item, statusMessage: "Editing \(item.kind.displayName.lowercased()) on page \(item.pageLabel).") openEditor( title: editorTitle, 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)) let targetPages = targets.map(\.page) guard confirmDiscardSidebarReplyDraftIfNeeded( deleting: targetIDs, actionName: targets.count > 1 ? "deleting this comment thread" : "deleting this comment" ) else { return } 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 } clearSidebarReplyDraftIfNeeded(deleting: targetIDs) activeEditor = nil refreshAnnotations(on: targetPages) statusMessage = targets.count > 1 ? "Comment thread deleted." : "Comment deleted." } func toggleReviewed(_ item: AnnotationSnapshot) { select(item, statusMessage: "\(item.kind.displayName) on page \(item.pageLabel).") 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 } hasUnsavedChanges = true pdfView?.annotationsChanged(on: item.page) refreshAnnotations(on: [item.page]) statusMessage = isReviewed ? "Marked as not reviewed." : "Marked as reviewed." } func saveEditor( _ context: AnnotationEditorContext, text: String, author: String ) { if shouldDiscardEmptyNewAnnotation(context, text: text) { let discardedMessage = context.annotations.contains { AcademicAnnotationKind(annotation: $0) == .freeText } ? "Empty free text discarded." : "Empty comment discarded." deleteAnnotations(in: context) statusMessage = discardedMessage return } updateAnnotations(in: context, text: text, author: author) refreshAnnotations(on: context.pages) 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(on: context.pages) } func deleteAnnotations(in context: AnnotationEditorContext) { let contextAnnotations = Set(context.annotations.map(ObjectIdentifier.init)) let contextSnapshots = annotations.filter { snapshot in contextAnnotations.contains(ObjectIdentifier(snapshot.annotation)) } let contextIDs = Set(contextSnapshots.map(\.id)) let targets = contextIDs.isEmpty ? contextSnapshots : annotations.filter { candidate in contextIDs.contains(candidate.id) || candidate.parentID.map(contextIDs.contains) == true } let targetIDs = Set(targets.map(\.id)) let targetPages = targets.map(\.page) guard confirmDiscardSidebarReplyDraftIfNeeded( deleting: targetIDs, actionName: targets.count > 1 ? "deleting this comment thread" : "deleting this annotation" ) else { return } if targets.isEmpty { for (index, annotation) in context.annotations.enumerated() { guard index < context.pages.count else { continue } removeAnnotation(annotation, from: context.pages[index]) } } else { for target in targets { removeAnnotation(target.annotation, from: target.page) } } activeEditor = nil if targetIDs.isEmpty || selectedAnnotationID.map(targetIDs.contains) == true { selectedAnnotationID = nil } if hoveredAnnotationID.map(targetIDs.contains) == true { hoveredAnnotationID = nil } clearSidebarReplyDraftIfNeeded(deleting: targetIDs) refreshAnnotations(on: targetPages.isEmpty ? context.pages : targetPages) if context.isNewAnnotation { hasUnsavedChanges = context.hadUnsavedChangesBeforeCreation } statusMessage = targets.count > 1 ? "Comment thread deleted." : "Annotation deleted." } func select(_ item: AnnotationSnapshot) { select(item, statusMessage: "\(item.kind.displayName) on page \(item.pageLabel).") } func selectHighlightedText(_ item: AnnotationSnapshot) { select(item, statusMessage: "Highlight on page \(item.pageLabel).") } private func select(_ item: AnnotationSnapshot, statusMessage message: String) { clearHoveredAnnotation() clearHighlightedAnnotation() let visibleTarget = visibleAnnotationTarget(for: item) selectedAnnotationID = item.id visibleTarget.annotation.isHighlighted = true pdfView?.go(to: visibleTarget.bounds.insetBy(dx: -24, dy: -24), on: visibleTarget.page) pdfView?.annotationsChanged(on: visibleTarget.page) statusMessage = message } func setCommentHover(_ item: AnnotationSnapshot, isHovered: Bool) { let visibleTarget = visibleAnnotationTarget(for: item) if isHovered { clearHoveredAnnotation(except: item.id) hoveredAnnotationID = item.id visibleTarget.annotation.isHighlighted = true pdfView?.annotationsChanged(on: visibleTarget.page) return } guard hoveredAnnotationID == item.id else { return } hoveredAnnotationID = nil guard !isSelectedVisibleTarget(visibleTarget) else { return } visibleTarget.annotation.isHighlighted = false pdfView?.annotationsChanged(on: visibleTarget.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(on pages: [PDFPage]? = nil) { guard let document else { annotations = [] clearSidebarReplyDraft() return } if let pages { let trackedPages = uniquePages(pages, in: document) guard !trackedPages.isEmpty else { pruneSidebarReplyDraftIfNeeded() return } for trackedPage in trackedPages { hideReplyMarkers(on: trackedPage.page) normalizePopupMarkers(on: trackedPage.page) hidePopupMarkersInViewer(on: trackedPage.page) } let updatedIndexes = Set(trackedPages.map(\.index)) let updatedPages = trackedPages.map(\.page) let updatedSnapshots = AnnotationReader.snapshots(in: document, pages: updatedPages) annotations = AnnotationReader.sorted( annotations.filter { !updatedIndexes.contains($0.pageIndex) } + updatedSnapshots ) pruneSidebarReplyDraftIfNeeded() return } hideReplyMarkers(in: document) normalizePopupMarkers(in: document) hidePopupMarkersInViewer(in: document) annotations = AnnotationReader.snapshots(in: document) pruneSidebarReplyDraftIfNeeded() } private func refreshBookmarks(for url: URL, pageCount: Int) { bookmarks = PDFDocumentBookmarks.clamped( AppDefaults.bookmarks(for: url), pageCount: pageCount ) AppDefaults.setBookmarks(bookmarks, for: url) } func runSearch() { guard let document else { return } let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { clearSearchResults() 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 currentSearchIndex = 0 activeSearchQuery = query guard !results.isEmpty else { pdfView?.highlightedSelections = nil pdfView?.clearSelection() statusMessage = "No matches for \(query)." return } pdfView?.highlightedSelections = results goToSearchResult(at: currentSearchIndex) } func showSearch() { guard document != nil else { return } showToolbarSearch = true statusMessage = "Search ready." requestSearchFieldFocus() } private func requestSearchFieldFocus() { guard showToolbarSearch else { return } toolbarSearchFocusRequest += 1 } func hideSearch() { clearSearchState() statusMessage = "Search closed." } func clearSearchQuery() { searchText = "" clearSearchResults() statusMessage = "Search cleared." requestSearchFieldFocus() } 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() { guard let pdfView else { return } pdfView.displayMode = .singlePageContinuous pdfView.displayDirection = .vertical pdfView.displaysAsBook = false pdfView.autoScales = true pdfView.layoutDocumentView() statusMessage = "Fit to width." } func fitPage() { guard let pdfView else { return } pdfView.displayMode = .singlePage pdfView.displaysAsBook = false pdfView.autoScales = true pdfView.layoutDocumentView() statusMessage = "Fit to page." } func twoPageContinuous() { guard let pdfView else { return } pdfView.displayMode = .twoUpContinuous pdfView.displayDirection = .vertical pdfView.displaysAsBook = false pdfView.autoScales = true pdfView.layoutDocumentView() statusMessage = "Two pages continuous." } func goToPageFromField() { guard let document else { return } let pageCount = document.pageCount let trimmedPageText = pageText.trimmingCharacters(in: .whitespacesAndNewlines) guard let target = Int(trimmedPageText) else { updateCurrentPageState() statusMessage = "Enter a page number from 1 to \(pageCount)." return } guard target >= 1, target <= pageCount else { updateCurrentPageState() statusMessage = "Page must be between 1 and \(pageCount)." return } guard let page = document.page(at: target - 1) else { updateCurrentPageState() statusMessage = "Page \(target) is unavailable." 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() statusMessage = "Already on the first page." 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() statusMessage = "Already on the last page." 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, opensEditor: Bool ) { 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, highlightColor: AppSettings.highlightColor, commentColor: AppSettings.commentColor ) guard !insertions.isEmpty else { statusMessage = "No selectable text was found in the selection." return } let hadUnsavedChangesBeforeCreation = hasUnsavedChanges for insertion in insertions { add(insertion) } pdfView?.clearSelection() updateTextSelectionState() refreshAnnotations(on: insertions.map(\.page)) guard opensEditor else { statusMessage = "Highlighted selection." return } openEditor( title: title, annotations: insertions.map(\.annotation), pages: insertions.map(\.page), isNew: true, hadUnsavedChangesBeforeCreation: hadUnsavedChangesBeforeCreation ) switch style { case .highlight: statusMessage = "Highlighted selection." case .comment: statusMessage = "Adding comment to selection." case .underline: statusMessage = "Adding underline comment." } } private func add(_ insertion: AnnotationInsertion) { insertion.page.addAnnotation(insertion.annotation) if AnnotationKeys.isReply(insertion.annotation) { AnnotationFactory.hideReplyMarker(insertion.annotation, on: insertion.page) } detachPopupMarkerFromViewer(for: insertion.annotation, on: insertion.page) hasUnsavedChanges = true pdfView?.annotationsChanged(on: insertion.page) } private func updateTextSelectionState() { guard let selection = pdfView?.currentSelection, !selection.pages.isEmpty, selection.string?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { hasTextSelection = false return } hasTextSelection = true } 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] _ = AnnotationFactory.updateComment( for: annotation, on: page, text: text, author: authorValue ) detachPopupMarkerFromViewer(for: annotation, on: page) hasUnsavedChanges = true pdfView?.annotationsChanged(on: page) } } private func replaceAnnotation( _ annotation: PDFAnnotation, with replacement: PDFAnnotation, on page: PDFPage ) { let insertionIndex = page.annotations.firstIndex { $0 === annotation } 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) page.addAnnotation(replacement) if let insertionIndex, insertionIndex < page.annotations.count - 1 { let tail = page.annotations.dropFirst(insertionIndex).filter { $0 !== replacement } for annotation in tail { page.removeAnnotation(annotation) } for annotation in tail { page.addAnnotation(annotation) } } detachPopupMarkerFromViewer(for: replacement, on: page) hasUnsavedChanges = true pdfView?.annotationsChanged(on: page) } private func shouldDiscardEmptyNewAnnotation( _ context: AnnotationEditorContext, text: String ) -> Bool { guard context.isNewAnnotation, !context.annotations.isEmpty, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } return context.annotations.allSatisfy { annotation in let kind = AcademicAnnotationKind(annotation: annotation) return kind == .comment || kind == .freeText } } @discardableResult private func discardEmptyActiveEditorBeforeWritingIfNeeded() -> Bool { guard let context = activeEditor else { return false } let text = context.annotations .map(AnnotationKeys.commentText(for:)) .joined(separator: "\n") guard shouldDiscardEmptyNewAnnotation(context, text: text) else { return false } deleteAnnotations(in: context) statusMessage = context.annotations.contains { AcademicAnnotationKind(annotation: $0) == .freeText } ? "Empty free text discarded." : "Empty comment discarded." return true } 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) hasUnsavedChanges = true pdfView?.annotationsChanged(on: page) } private func openEditor( title: String, annotations: [PDFAnnotation], pages: [PDFPage], isNew: Bool, hadUnsavedChangesBeforeCreation: Bool = false, allowsReply: Bool = true ) { closeNativePopups(on: pages) let first = annotations.first activeEditor = AnnotationEditorContext( title: title, annotations: annotations, pages: pages, isNewAnnotation: isNew, hadUnsavedChangesBeforeCreation: hadUnsavedChangesBeforeCreation, allowsDelete: true, allowsReply: allowsReply, initialText: first.map(AnnotationKeys.commentText(for:)) ?? "", 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 rootComment(for target: AnnotationSnapshot) -> AnnotationSnapshot? { guard let parentID = target.parentID else { return target } return annotations.first { $0.id == parentID } } private func isCommentReviewItem(_ item: AnnotationSnapshot) -> Bool { if item.kind == .highlight, !item.hasComment { return false } return item.isReply ? item.hasComment : true } private func snapshot(for annotation: PDFAnnotation) -> AnnotationSnapshot? { annotations.first { $0.annotation === annotation } } private func visibleAnnotationTarget(for item: AnnotationSnapshot) -> AnnotationSnapshot { guard let parentID = item.parentID, let parent = annotations.first(where: { $0.id == parentID }) else { return item } return parent } private func isSelectedVisibleTarget(_ candidate: AnnotationSnapshot) -> Bool { guard let selectedAnnotationID, let selected = annotations.first(where: { $0.id == selectedAnnotationID }) else { return false } return visibleAnnotationTarget(for: selected).id == candidate.id } private func clearCommentReviewHighlightsHiddenBySidebarVisibility() { clearHoveredAnnotation() clearSelectedAnnotationIfHiddenBySidebarState() } private func clearSelectedAnnotationIfHiddenBySidebarState() { guard let selectedAnnotationID else { return } guard !visibleSidebarAnnotationIDs().contains(selectedAnnotationID) else { return } clearHighlightedAnnotation() self.selectedAnnotationID = nil } private func visibleSidebarAnnotationIDs() -> Set { var visibleIDs = Set() if showLeftSidebar, leftSidebarMode == .annotations { visibleIDs.formUnion(annotations.map(\.id)) } guard showCommentsSidebar else { return visibleIDs } switch sidebarMode { case .highlights: visibleIDs.formUnion(annotations.filter { $0.kind == .highlight }.map(\.id)) return visibleIDs case .pages: return visibleIDs case .annotations: break } let visibleTopLevel = topLevelComments.filter { item in guard pageCount > 1, !isFilteringCommentReview else { return true } return !collapsedPageIndexes.contains(item.pageIndex) } visibleIDs.formUnion( visibleTopLevel.map(\.id) + visibleTopLevel.flatMap { repliesByParent[$0.id] ?? [] }.map(\.id) ) return visibleIDs } private var isFilteringCommentReview: Bool { !commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || commentFilter != .all || selectedKindFilter != nil || selectedAuthorFilter != "All Authors" || selectedStatusFilter != ReviewState.allStatuses } private func highlightColorKey(for item: AnnotationSnapshot) -> String { AnnotationColorPreference.storageString( for: item.annotation.color, fallback: AppSettings.defaultHighlightColorStorageValue ) } private func highlightColor(for key: String) -> NSColor { AnnotationColorPreference.color( from: key, fallback: AcademicAnnotationPalette.highlight, minimumAlpha: 0.38 ) } private func highlightColorTitle(for key: String) -> String { let selected = highlightColor(for: key) let selectedStorage = AppSettings.storageString(forHighlightColor: selected) for swatch in AppSettings.highlightSwatches { if AppSettings.storageString(forHighlightColor: swatch.color) == selectedStorage { return swatch.name } } return "Custom" } private func clearSidebarReplyDraft() { sidebarReplyParentID = nil sidebarReplyTargetID = nil sidebarReplyDraft = "" sidebarReplyAuthor = AnnotationFactory.defaultAuthor } private var hasSidebarReplyDraft: Bool { !sidebarReplyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func clearSidebarReplyDraftIfNeeded(deleting targetIDs: Set) { guard sidebarReplyDraftWouldBeDiscarded(deleting: targetIDs) else { return } clearSidebarReplyDraft() } private func sidebarReplyDraftWouldBeDiscarded(deleting targetIDs: Set) -> Bool { guard hasSidebarReplyDraft else { return false } return sidebarReplyParentID.map(targetIDs.contains) == true || sidebarReplyTargetID.map(targetIDs.contains) == true } private func pruneSidebarReplyDraftIfNeeded() { guard sidebarReplyParentID != nil || sidebarReplyTargetID != nil else { return } let ids = Set(annotations.map(\.id)) guard let parentID = sidebarReplyParentID, ids.contains(parentID) else { clearSidebarReplyDraft() return } if sidebarReplyTargetID.map(ids.contains) != true { sidebarReplyTargetID = parentID } } private func uniquePages(_ pages: [PDFPage], in document: PDFDocument) -> [(page: PDFPage, index: Int)] { var seenIndexes = Set() return pages.compactMap { page in let index = document.index(for: page) guard index != NSNotFound, seenIndexes.insert(index).inserted else { return nil } return (page: page, index: index) } } private func configure(_ view: PDFView) { view.displayMode = .singlePageContinuous view.displayDirection = .vertical view.displaysPageBreaks = true view.pageBreakMargins = PDFReadingLayout.pageBreakMargins view.displayBox = .cropBox view.autoScales = true view.minScaleFactor = PDFReadingLayout.minimumScaleFactor view.maxScaleFactor = PDFReadingLayout.maximumReadingScaleFactor view.interpolationQuality = .high view.backgroundColor = NSColor.underPageBackgroundColor view.acceptsDraggedFiles = false view.pageShadowsEnabled = true } @discardableResult private func write(_ document: PDFDocument, to url: URL) -> Bool { prepareAnnotationsForExport(in: document) guard document.write(to: url) else { hidePopupMarkersInViewer(in: document) showAlert(title: "Save Failed", message: "The PDF could not be written to \(url.path).") return false } refreshAnnotations() hasUnsavedChanges = false statusMessage = "Saved \(url.lastPathComponent)." return true } private func confirmDiscardOrSaveUnsavedChanges(actionName: String) -> Bool { guard confirmDiscardOrSaveAnnotationChanges(actionName: actionName) else { return false } return confirmDiscardSidebarReplyDraft(actionName: actionName) } private func confirmDiscardOrSaveAnnotationChanges(actionName: String) -> Bool { guard hasUnsavedChanges else { return true } let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Save Changes?" if let fileName = documentURL?.lastPathComponent { alert.informativeText = "This PDF has unsaved annotations. Saving writes them directly into \(fileName). Save before \(actionName), discard the changes, or cancel." } else { alert.informativeText = "This PDF has unsaved annotations. Save an annotated copy before \(actionName), discard the changes, or cancel." } alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Discard Changes") alert.addButton(withTitle: "Cancel") switch alert.runModal() { case .alertFirstButtonReturn: return saveDocument(confirmOverwrite: false, confirmReplyDraft: false) case .alertSecondButtonReturn: return true default: return false } } private func confirmDiscardSidebarReplyDraft(actionName: String) -> Bool { guard hasSidebarReplyDraft else { return true } let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Discard Reply Draft?" alert.informativeText = "You have an unsent reply draft. Send it, cancel it, or discard it before \(actionName)." alert.addButton(withTitle: "Discard Reply Draft") alert.addButton(withTitle: "Cancel") return alert.runModal() == .alertFirstButtonReturn } private func confirmSaveWithoutSidebarReplyDraft() -> Bool { guard hasSidebarReplyDraft else { return true } let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Save Without Reply Draft?" alert.informativeText = "Your sidebar reply draft has not been added to the PDF yet. Send it before saving if it should be included, or save without that draft." alert.addButton(withTitle: "Save Without Draft") alert.addButton(withTitle: "Cancel") return alert.runModal() == .alertFirstButtonReturn } private func confirmDiscardSidebarReplyDraftIfNeeded( deleting targetIDs: Set, actionName: String ) -> Bool { guard sidebarReplyDraftWouldBeDiscarded(deleting: targetIDs) else { return true } return confirmDiscardSidebarReplyDraft(actionName: actionName) } private func confirmShareWithoutSidebarReplyDraft() -> Bool { guard hasSidebarReplyDraft else { return true } let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Share Without Reply Draft?" alert.informativeText = "Your sidebar reply draft has not been added to the PDF yet. Send it before sharing, or share without that draft." alert.addButton(withTitle: "Share Without Draft") alert.addButton(withTitle: "Cancel") return alert.runModal() == .alertFirstButtonReturn } private func presentSharePicker(for url: URL) { guard let contentView = pdfView?.window?.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) statusMessage = "Search match \(index + 1) of \(searchResults.count)." } private func clearSearchState() { activeSearchQuery = nil searchText = "" showToolbarSearch = false clearSearchResults() } private func clearSearchResults() { activeSearchQuery = nil searchResults = [] currentSearchIndex = 0 pdfView?.highlightedSelections = nil pdfView?.clearSelection() } private func clearSearchResultsForEditedQuery() { guard let activeSearchQuery, searchText.trimmingCharacters(in: .whitespacesAndNewlines) != activeSearchQuery else { return } clearSearchResults() } private func hideReplyMarkers(in document: PDFDocument) { for pageIndex in 0.. Bool { var didChange = false for annotation in page.annotations where AnnotationKeys.isReply(annotation) { AnnotationFactory.hideReplyMarker(annotation, on: page) didChange = true } if didChange { pdfView?.annotationsChanged(on: page) } return didChange } private func normalizePopupMarkers(in document: PDFDocument) { for pageIndex in 0.. Bool { var didChange = false for annotation in page.annotations where !AnnotationKeys.annotation(annotation, hasSubtype: .popup) { if AnnotationFactory.normalizePopupPlacement(for: annotation, on: page) { didChange = true } } if didChange { pdfView?.annotationsChanged(on: page) } return didChange } private func prepareAnnotationsForExport(in document: PDFDocument) { var changedPages = Set() for pageIndex in 0.. Bool { var didChange = false var popupsToRemove: [PDFAnnotation] = [] for annotation in page.annotations { if AnnotationKeys.annotation(annotation, hasSubtype: .popup) { annotation.isOpen = false annotation.shouldDisplay = false annotation.shouldPrint = false popupsToRemove.append(annotation) continue } if detachPopupMarkerFromViewer(for: annotation, on: page) { didChange = true } } for popup in popupsToRemove { page.removeAnnotation(popup) } didChange = didChange || !popupsToRemove.isEmpty if didChange { pdfView?.annotationsChanged(on: page) } return didChange } @discardableResult private func detachPopupMarkerFromViewer(for annotation: PDFAnnotation, on page: PDFPage) -> Bool { AnnotationFactory.detachPopupForViewer(from: annotation, on: page) } 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)" persistCurrentPageProgress() statusMessage = "Page \(pageIndex + 1) of \(pageCount)." } private func goToInitialPage(_ pageIndex: Int, in document: PDFDocument) { let targetIndex = PDFRecentDocuments.clampedPageIndex(pageIndex, pageCount: document.pageCount) guard let page = document.page(at: targetIndex), let pdfView else { pendingInitialPageIndex = targetIndex currentPageIndex = targetIndex pageText = "\(targetIndex + 1)" return } pendingInitialPageIndex = nil 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 = targetIndex pageText = "\(targetIndex + 1)" persistCurrentPageProgress() } 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)" persistCurrentPageProgress() } private func persistCurrentPageProgress() { guard let documentURL, document != nil else { return } AppDefaults.setPageProgress(url: documentURL, pageIndex: currentPageIndex) } private func fitOpenedDocumentToScreen() { applyOpenFitToView() DispatchQueue.main.async { [weak self] in self?.applyOpenFitToView() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) { [weak self] in self?.applyOpenFitToView() } } private func applyOpenFitToView() { guard let pdfView, pdfView.document != nil else { return } pdfView.displayMode = .singlePageContinuous pdfView.displayDirection = .vertical pdfView.displaysAsBook = false pdfView.autoScales = true pdfView.layoutDocumentView() } private func prepareDocumentViewForOpenAnimation() { guard let pdfView else { return } pdfView.wantsLayer = true pdfView.alphaValue = 0 } private func animateDocumentViewIn() { guard let pdfView else { return } pdfView.wantsLayer = true NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 pdfView.animator().alphaValue = 1 } } private func animateDocumentViewOut(completion: @escaping () -> Void) { guard let pdfView else { completion() return } pdfView.wantsLayer = true NSAnimationContext.runAnimationGroup { context in context.duration = 0.12 pdfView.animator().alphaValue = 0 } completionHandler: { pdfView.alphaValue = 1 completion() } } private func clearHighlightedAnnotation() { guard let selectedAnnotationID, let previous = annotations.first(where: { $0.id == selectedAnnotationID }) else { return } let visibleTarget = visibleAnnotationTarget(for: previous) visibleTarget.annotation.isHighlighted = false pdfView?.annotationsChanged(on: visibleTarget.page) } private func clearHoveredAnnotation(except keptID: String? = nil) { guard let hoveredAnnotationID, hoveredAnnotationID != keptID else { return } defer { self.hoveredAnnotationID = nil } guard let previous = annotations.first(where: { $0.id == hoveredAnnotationID }) else { return } let visibleTarget = visibleAnnotationTarget(for: previous) guard !isSelectedVisibleTarget(visibleTarget) else { return } visibleTarget.annotation.isHighlighted = false pdfView?.annotationsChanged(on: visibleTarget.page) } 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 resetToFocusedReadingLayout() { leftSidebarMode = .pages sidebarMode = .annotations showLeftSidebar = false showCommentsSidebar = false enforceCompactSidebarRules() } private func clearOpenDocumentState() { selectedAnnotationID = nil activeEditor = nil placementTool = nil pendingInitialPageIndex = nil isHighlighterModeActive = false hasTextSelection = false clearSearchState() resetCommentReviewState() clearSidebarReplyDraft() resetToFocusedReadingLayout() pdfView?.document = nil document = nil documentURL = nil annotations = [] bookmarks = [] hasUnsavedChanges = false currentPageIndex = 0 pageText = "1" } private func enforceCompactSidebarRules() { guard !ReaderAdaptiveLayout(sizeClass: readerSizeClass).allowsDualSidebars else { return } if showLeftSidebar && showCommentsSidebar { showLeftSidebar = false } } }