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 = [] @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 } hideReplyMarkers(in: document) 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 hideReplyMarkers(in document: PDFDocument) { var changedPages = Set() for pageIndex in 0.. 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)" } }