2026-06-11 18:12:13 -07:00
import AppKit
import Foundation
import IHatePDFsCore
import PDFKit
import SwiftUI
2026-06-24 17:51:26 -07:00
import UniformTypeIdentifiers
2026-06-11 18:12:13 -07:00
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
2026-06-24 17:51:26 -07:00
let hadUnsavedChangesBeforeCreation : Bool
2026-06-11 18:12:13 -07:00
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 ( )
2026-06-24 17:51:26 -07:00
clearSelectedAnnotationIfHiddenBySidebarState ( )
2026-06-11 18:12:13 -07:00
}
}
@ Published var showCommentsSidebar = false {
didSet {
persistSidebarPreferenceIfNeeded ( )
2026-06-24 17:51:26 -07:00
if ! showCommentsSidebar {
clearHoveredAnnotation ( )
}
clearSelectedAnnotationIfHiddenBySidebarState ( )
2026-06-11 18:12:13 -07:00
}
}
2026-06-24 17:51:26 -07:00
@ Published var sidebarMode : SidebarMode = . pages {
didSet { clearSelectedAnnotationIfHiddenBySidebarState ( ) }
}
@ Published var searchText = " " {
didSet { clearSearchResultsForEditedQuery ( ) }
}
2026-06-11 18:12:13 -07:00
@ Published var showToolbarSearch = false
@ Published var searchResults : [ PDFSelection ] = [ ]
2026-06-24 17:51:26 -07:00
@ Published var hasTextSelection = false
2026-06-11 18:12:13 -07:00
@ Published var currentSearchIndex = 0
@ Published var pageText = " 1 "
@ Published var currentPageIndex = 0
2026-06-24 17:51:26 -07:00
@ 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 collapsedPageIndexes : Set < Int > = [ ] {
didSet { clearCommentReviewHighlightsHiddenBySidebarVisibility ( ) }
}
2026-06-11 22:03:43 -07:00
@ Published var sidebarReplyParentID : String ?
@ Published var sidebarReplyTargetID : String ?
@ Published var sidebarReplyDraft = " "
@ Published var sidebarReplyAuthor = AnnotationFactory . defaultAuthor
2026-06-24 17:51:26 -07:00
@ Published var hasUnsavedChanges = false
2026-06-11 18:12:13 -07:00
@ 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 ?
2026-06-24 17:51:26 -07:00
private var activeSearchQuery : String ?
2026-06-11 18:12:13 -07:00
var displayTitle : String {
documentURL ? . lastPathComponent ? ? " I Hate PDFs "
}
var pageCount : Int {
document ? . pageCount ? ? 0
}
2026-06-24 17:51:26 -07:00
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 canSaveDocument : Bool {
document != nil && hasUnsavedChanges
}
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 "
}
2026-06-11 18:12:13 -07:00
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 ] {
2026-06-24 17:51:26 -07:00
let query = commentSearchText . trimmingCharacters ( in : . whitespacesAndNewlines )
return annotations . filter { item in
2026-06-11 18:12:13 -07:00
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 ] {
2026-06-24 17:51:26 -07:00
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 ) )
}
2026-06-11 18:12:13 -07:00
}
var repliesByParent : [ String : [ AnnotationSnapshot ] ] {
2026-06-11 22:03:43 -07:00
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 }
2026-06-11 18:12:13 -07:00
}
2026-06-24 17:51:26 -07:00
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 = [ ]
}
2026-06-11 18:12:13 -07:00
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
2026-06-24 17:51:26 -07:00
guard let self else { return }
self . updateTextSelectionState ( )
guard self . placementTool = = nil , self . hasTextSelection else { return }
self . statusMessage = " Selection ready for annotation. "
2026-06-11 18:12:13 -07:00
}
}
2026-06-24 17:51:26 -07:00
updateTextSelectionState ( )
2026-06-11 18:12:13 -07:00
}
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 )
}
2026-06-24 17:51:26 -07:00
@ 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 }
}
2026-06-11 18:12:13 -07:00
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
2026-06-24 17:51:26 -07:00
clearSearchState ( )
resetCommentReviewState ( )
2026-06-11 18:12:13 -07:00
selectedAnnotationID = nil
activeEditor = nil
placementTool = nil
2026-06-24 17:51:26 -07:00
hasTextSelection = false
hasUnsavedChanges = false
2026-06-11 22:03:43 -07:00
clearSidebarReplyDraft ( )
2026-06-11 18:12:13 -07:00
refreshAnnotations ( )
statusMessage = " Opened \( url . lastPathComponent ) . "
}
func closeDocument ( ) {
2026-06-24 17:51:26 -07:00
guard confirmDiscardOrSaveUnsavedChanges ( actionName : " closing this PDF " ) else { return }
2026-06-11 18:12:13 -07:00
persistSidebarPreferenceIfNeeded ( )
document = nil
documentURL = nil
annotations = [ ]
selectedAnnotationID = nil
activeEditor = nil
placementTool = nil
2026-06-24 17:51:26 -07:00
hasTextSelection = false
hasUnsavedChanges = false
2026-06-11 22:03:43 -07:00
clearSidebarReplyDraft ( )
2026-06-24 17:51:26 -07:00
clearSearchState ( )
resetCommentReviewState ( )
2026-06-11 18:12:13 -07:00
pageText = " 1 "
currentPageIndex = 0
pdfView ? . document = nil
applySidebarPreference ( . defaultReading )
statusMessage = " Open a PDF to begin. "
}
2026-06-24 17:51:26 -07:00
func confirmDocumentWindowClose ( ) -> Bool {
guard confirmDiscardOrSaveUnsavedChanges ( actionName : " closing this window " ) else {
return false
}
persistSidebarPreferenceIfNeeded ( )
return true
}
func confirmApplicationQuit ( ) -> Bool {
guard confirmDiscardOrSaveUnsavedChanges ( actionName : " quitting the app " ) else {
return false
}
persistSidebarPreferenceIfNeeded ( )
return true
}
2026-06-11 18:12:13 -07:00
func saveDocument ( ) {
2026-06-24 17:51:26 -07:00
_ = 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 }
}
2026-06-11 18:12:13 -07:00
if let url = documentURL {
2026-06-24 17:51:26 -07:00
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 )
2026-06-11 18:12:13 -07:00
} else {
2026-06-24 17:51:26 -07:00
return saveDocumentAs ( confirmReplyDraft : confirmReplyDraft )
2026-06-11 18:12:13 -07:00
}
}
2026-06-24 17:51:26 -07:00
@ 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 }
}
2026-06-11 18:12:13 -07:00
let panel = NSSavePanel ( )
panel . allowedContentTypes = [ . pdf ]
panel . canCreateDirectories = true
panel . title = " Save Annotated PDF "
panel . nameFieldStringValue = suggestedAnnotatedFilename ( )
2026-06-24 17:51:26 -07:00
guard panel . runModal ( ) = = . OK , let url = panel . url else { return false }
guard write ( document , to : url ) else { return false }
2026-06-11 18:12:13 -07:00
documentURL = url
persistSidebarPreferenceIfNeeded ( )
2026-06-24 17:51:26 -07:00
return true
2026-06-11 18:12:13 -07:00
}
func shareDocument ( ) {
guard let document else { return }
2026-06-24 17:51:26 -07:00
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 ) . "
2026-06-11 18:12:13 -07:00
return
}
let alert = NSAlert ( )
2026-06-24 17:51:26 -07:00
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. "
2026-06-11 18:12:13 -07:00
alert . addButton ( withTitle : " Save and Share " )
2026-06-24 17:51:26 -07:00
alert . addButton ( withTitle : " Share Last Saved Version " )
2026-06-11 18:12:13 -07:00
alert . addButton ( withTitle : " Cancel " )
switch alert . runModal ( ) {
case . alertFirstButtonReturn :
2026-06-24 17:51:26 -07:00
guard write ( document , to : url ) else { return }
2026-06-11 18:12:13 -07:00
case . alertSecondButtonReturn :
2026-06-24 17:51:26 -07:00
statusMessage = " Sharing last saved version; unsaved annotations remain open. "
2026-06-11 18:12:13 -07:00
break
default :
return
}
presentSharePicker ( for : url )
statusMessage = " Ready to share \( url . lastPathComponent ) . "
}
func addHighlight ( ) {
2026-06-24 17:51:26 -07:00
addMarkup ( style : . highlight , title : " Highlight " , opensEditor : false )
2026-06-11 18:12:13 -07:00
}
func addUnderline ( ) {
2026-06-24 17:51:26 -07:00
addMarkup ( style : . underline , title : " Underline Comment " , opensEditor : true )
2026-06-11 18:12:13 -07:00
}
func addComment ( ) {
2026-06-24 17:51:26 -07:00
addMarkup ( style : . comment , title : " Comment " , opensEditor : true )
2026-06-11 18:12:13 -07:00
}
func addFreeText ( ) {
guard document != nil else {
statusMessage = " Open a PDF before adding free text. "
return
}
activeEditor = nil
placementTool = . freeText
2026-06-24 17:51:26 -07:00
pdfView ? . window ? . makeFirstResponder ( pdfView )
2026-06-11 18:12:13 -07:00
statusMessage = " Click on the page to place free text. "
}
2026-06-24 17:51:26 -07:00
func cancelPlacementTool ( ) {
guard placementTool != nil else { return }
placementTool = nil
statusMessage = " Free text placement canceled. "
}
2026-06-11 18:12:13 -07:00
func placePendingAnnotation ( on page : PDFPage , near point : CGPoint ) {
guard let placementTool else { return }
let insertion : AnnotationInsertion
let title : String
2026-06-24 17:51:26 -07:00
let hadUnsavedChangesBeforeCreation = hasUnsavedChanges
2026-06-11 18:12:13 -07:00
switch placementTool {
case . freeText :
insertion = AnnotationFactory . freeTextInsertion (
on : page ,
near : point ,
text : " " ,
author : AnnotationFactory . defaultAuthor
)
title = " Free Text "
}
self . placementTool = nil
add ( insertion )
2026-06-24 17:51:26 -07:00
refreshAnnotations ( on : [ page ] )
2026-06-11 18:12:13 -07:00
openEditor (
title : title ,
annotations : [ insertion . annotation ] ,
pages : [ page ] ,
2026-06-24 17:51:26 -07:00
isNew : true ,
hadUnsavedChangesBeforeCreation : hadUnsavedChangesBeforeCreation
2026-06-11 18:12:13 -07:00
)
}
func addReply ( to item : AnnotationSnapshot ) {
2026-06-11 22:03:43 -07:00
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
}
2026-06-24 17:51:26 -07:00
if hasSidebarReplyDraft {
guard sidebarReplyParentID = = root . id ,
sidebarReplyTargetID = = target . id
else {
showCommentsSidebar = true
statusMessage = " Finish or cancel the current reply before starting another. "
return
}
showCommentsSidebar = true
select ( target , statusMessage : " Reply draft is already open. " )
return
}
2026-06-11 22:03:43 -07:00
activeEditor = nil
showCommentsSidebar = true
sidebarReplyParentID = root . id
sidebarReplyTargetID = target . id
sidebarReplyDraft = " "
sidebarReplyAuthor = AnnotationFactory . defaultAuthor
2026-06-24 17:51:26 -07:00
select ( target , statusMessage : " Replying to \( target . author ) . " )
2026-06-11 22:03:43 -07:00
}
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 )
2026-06-11 18:12:13 -07:00
let insertion = AnnotationFactory . replyInsertion (
2026-06-11 22:03:43 -07:00
to : parent . annotation ,
on : parent . page ,
comment : trimmedText ,
author : author . isEmpty ? AnnotationFactory . defaultAuthor : author ,
parentID : parent . id
2026-06-11 18:12:13 -07:00
)
add ( insertion )
2026-06-11 22:03:43 -07:00
clearSidebarReplyDraft ( )
2026-06-24 17:51:26 -07:00
refreshAnnotations ( on : [ parent . page ] )
2026-06-11 22:03:43 -07:00
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 )
2026-06-24 17:51:26 -07:00
refreshAnnotations ( on : context . pages )
2026-06-11 22:03:43 -07:00
guard let annotation = context . primaryAnnotation ,
let item = snapshot ( for : annotation )
else {
activeEditor = nil
showCommentsSidebar = true
statusMessage = " Comment saved. "
return
}
activeEditor = nil
beginSidebarReply ( to : item )
2026-06-11 18:12:13 -07:00
}
func edit ( _ item : AnnotationSnapshot ) {
2026-06-24 17:51:26 -07:00
select ( item , statusMessage : " Editing \( item . kind . displayName . lowercased ( ) ) on page \( item . pageLabel ) . " )
2026-06-11 18:12:13 -07:00
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 ) )
2026-06-24 17:51:26 -07:00
let targetPages = targets . map ( \ . page )
guard confirmDiscardSidebarReplyDraftIfNeeded (
deleting : targetIDs ,
actionName : targets . count > 1 ? " deleting this comment thread " : " deleting this comment "
) else {
return
}
2026-06-11 18:12:13 -07:00
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
}
2026-06-24 17:51:26 -07:00
clearSidebarReplyDraftIfNeeded ( deleting : targetIDs )
2026-06-11 18:12:13 -07:00
activeEditor = nil
2026-06-24 17:51:26 -07:00
refreshAnnotations ( on : targetPages )
2026-06-11 18:12:13 -07:00
statusMessage = targets . count > 1 ? " Comment thread deleted. " : " Comment deleted. "
}
func toggleReviewed ( _ item : AnnotationSnapshot ) {
2026-06-24 17:51:26 -07:00
select ( item , statusMessage : " \( item . kind . displayName ) on page \( item . pageLabel ) . " )
2026-06-11 18:12:13 -07:00
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
}
2026-06-24 17:51:26 -07:00
hasUnsavedChanges = true
2026-06-11 18:12:13 -07:00
pdfView ? . annotationsChanged ( on : item . page )
2026-06-24 17:51:26 -07:00
refreshAnnotations ( on : [ item . page ] )
2026-06-11 18:12:13 -07:00
statusMessage = isReviewed ? " Marked as not reviewed. " : " Marked as reviewed. "
}
func saveEditor (
_ context : AnnotationEditorContext ,
text : String ,
author : String
) {
2026-06-24 17:51:26 -07:00
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
}
2026-06-11 18:12:13 -07:00
updateAnnotations ( in : context , text : text , author : author )
2026-06-24 17:51:26 -07:00
refreshAnnotations ( on : context . pages )
2026-06-11 18:12:13 -07:00
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 )
2026-06-24 17:51:26 -07:00
refreshAnnotations ( on : context . pages )
2026-06-11 18:12:13 -07:00
}
func deleteAnnotations ( in context : AnnotationEditorContext ) {
2026-06-24 17:51:26 -07:00
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 )
}
2026-06-11 18:12:13 -07:00
}
activeEditor = nil
2026-06-24 17:51:26 -07:00
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. "
2026-06-11 18:12:13 -07:00
}
func select ( _ item : AnnotationSnapshot ) {
2026-06-24 17:51:26 -07:00
select ( item , statusMessage : " \( item . kind . displayName ) on page \( item . pageLabel ) . " )
}
private func select ( _ item : AnnotationSnapshot , statusMessage message : String ) {
2026-06-11 18:12:13 -07:00
clearHoveredAnnotation ( )
clearHighlightedAnnotation ( )
2026-06-24 17:51:26 -07:00
let visibleTarget = visibleAnnotationTarget ( for : item )
2026-06-11 18:12:13 -07:00
selectedAnnotationID = item . id
2026-06-24 17:51:26 -07:00
visibleTarget . annotation . isHighlighted = true
pdfView ? . go ( to : visibleTarget . bounds . insetBy ( dx : - 24 , dy : - 24 ) , on : visibleTarget . page )
pdfView ? . annotationsChanged ( on : visibleTarget . page )
statusMessage = message
2026-06-11 18:12:13 -07:00
}
func setCommentHover ( _ item : AnnotationSnapshot , isHovered : Bool ) {
2026-06-24 17:51:26 -07:00
let visibleTarget = visibleAnnotationTarget ( for : item )
2026-06-11 18:12:13 -07:00
if isHovered {
clearHoveredAnnotation ( except : item . id )
hoveredAnnotationID = item . id
2026-06-24 17:51:26 -07:00
visibleTarget . annotation . isHighlighted = true
pdfView ? . annotationsChanged ( on : visibleTarget . page )
2026-06-11 18:12:13 -07:00
return
}
guard hoveredAnnotationID = = item . id else { return }
hoveredAnnotationID = nil
2026-06-24 17:51:26 -07:00
guard ! isSelectedVisibleTarget ( visibleTarget ) else { return }
visibleTarget . annotation . isHighlighted = false
pdfView ? . annotationsChanged ( on : visibleTarget . page )
2026-06-11 18:12:13 -07:00
}
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
)
}
}
2026-06-24 17:51:26 -07:00
func refreshAnnotations ( on pages : [ PDFPage ] ? = nil ) {
2026-06-11 18:12:13 -07:00
guard let document else {
annotations = [ ]
2026-06-11 22:03:43 -07:00
clearSidebarReplyDraft ( )
2026-06-11 18:12:13 -07:00
return
}
2026-06-24 17:51:26 -07:00
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
}
2026-06-11 18:34:41 -07:00
hideReplyMarkers ( in : document )
2026-06-11 22:03:43 -07:00
normalizePopupMarkers ( in : document )
hidePopupMarkersInViewer ( in : document )
2026-06-11 18:12:13 -07:00
annotations = AnnotationReader . snapshots ( in : document )
2026-06-11 22:03:43 -07:00
pruneSidebarReplyDraftIfNeeded ( )
2026-06-11 18:12:13 -07:00
}
func runSearch ( ) {
guard let document else { return }
let query = searchText . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! query . isEmpty else {
2026-06-24 17:51:26 -07:00
clearSearchResults ( )
2026-06-11 18:12:13 -07:00
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
2026-06-24 17:51:26 -07:00
activeSearchQuery = query
guard ! results . isEmpty else {
pdfView ? . highlightedSelections = nil
statusMessage = " No matches for \( query ) . "
return
}
pdfView ? . highlightedSelections = results
2026-06-11 18:12:13 -07:00
goToSearchResult ( at : currentSearchIndex )
}
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 ( ) {
2026-06-24 17:51:26 -07:00
clearSearchState ( )
statusMessage = " Search closed. "
2026-06-11 18:12:13 -07:00
}
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 ( ) {
2026-06-24 17:51:26 -07:00
guard let document else { return }
let pageCount = document . pageCount
let trimmedPageText = pageText . trimmingCharacters ( in : . whitespacesAndNewlines )
guard let target = Int ( trimmedPageText ) else {
2026-06-11 18:12:13 -07:00
updateCurrentPageState ( )
2026-06-24 17:51:26 -07:00
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. "
2026-06-11 18:12:13 -07:00
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 ( )
2026-06-24 17:51:26 -07:00
statusMessage = " Already on the first page. "
2026-06-11 18:12:13 -07:00
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 ( )
2026-06-24 17:51:26 -07:00
statusMessage = " Already on the last page. "
2026-06-11 18:12:13 -07:00
return
}
navigate ( to : page , pageIndex : index + 1 )
}
func toggleFullScreen ( ) {
NSApp . keyWindow ? . toggleFullScreen ( nil )
}
func minimizeWindow ( ) {
NSApp . keyWindow ? . miniaturize ( nil )
}
2026-06-24 17:51:26 -07:00
private func addMarkup (
style : MarkupAnnotationStyle ,
title : String ,
opensEditor : Bool
) {
2026-06-11 18:12:13 -07:00
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 : " " ,
2026-06-24 17:51:26 -07:00
author : AnnotationFactory . defaultAuthor ,
highlightColor : AppSettings . highlightColor ,
commentColor : AppSettings . commentColor
2026-06-11 18:12:13 -07:00
)
guard ! insertions . isEmpty else {
statusMessage = " No selectable text was found in the selection. "
return
}
2026-06-24 17:51:26 -07:00
let hadUnsavedChangesBeforeCreation = hasUnsavedChanges
2026-06-11 18:12:13 -07:00
for insertion in insertions {
add ( insertion )
}
pdfView ? . clearSelection ( )
2026-06-24 17:51:26 -07:00
updateTextSelectionState ( )
refreshAnnotations ( on : insertions . map ( \ . page ) )
guard opensEditor else {
statusMessage = " Highlighted selection. "
return
}
2026-06-11 18:12:13 -07:00
openEditor (
title : title ,
annotations : insertions . map ( \ . annotation ) ,
pages : insertions . map ( \ . page ) ,
2026-06-24 17:51:26 -07:00
isNew : true ,
hadUnsavedChangesBeforeCreation : hadUnsavedChangesBeforeCreation
2026-06-11 18:12:13 -07:00
)
}
private func add ( _ insertion : AnnotationInsertion ) {
insertion . page . addAnnotation ( insertion . annotation )
2026-06-11 22:03:43 -07:00
if AnnotationKeys . isReply ( insertion . annotation ) {
AnnotationFactory . hideReplyMarker ( insertion . annotation , on : insertion . page )
2026-06-11 18:12:13 -07:00
}
2026-06-11 22:03:43 -07:00
detachPopupMarkerFromViewer ( for : insertion . annotation , on : insertion . page )
2026-06-24 17:51:26 -07:00
hasUnsavedChanges = true
2026-06-11 18:12:13 -07:00
pdfView ? . annotationsChanged ( on : insertion . page )
}
2026-06-24 17:51:26 -07:00
private func updateTextSelectionState ( ) {
guard let selection = pdfView ? . currentSelection ,
! selection . pages . isEmpty ,
selection . string ? . trimmingCharacters ( in : . whitespacesAndNewlines ) . isEmpty = = false
else {
hasTextSelection = false
return
}
hasTextSelection = true
}
2026-06-11 18:12:13 -07:00
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 ]
2026-06-11 22:03:43 -07:00
_ = AnnotationFactory . updateComment (
2026-06-11 18:12:13 -07:00
for : annotation ,
on : page ,
text : text ,
author : authorValue
)
2026-06-11 22:03:43 -07:00
detachPopupMarkerFromViewer ( for : annotation , on : page )
2026-06-24 17:51:26 -07:00
hasUnsavedChanges = true
2026-06-11 18:12:13 -07:00
pdfView ? . annotationsChanged ( on : page )
}
}
2026-06-24 17:51:26 -07:00
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
}
2026-06-11 18:12:13 -07:00
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 )
2026-06-24 17:51:26 -07:00
hasUnsavedChanges = true
2026-06-11 18:12:13 -07:00
pdfView ? . annotationsChanged ( on : page )
}
private func openEditor (
title : String ,
annotations : [ PDFAnnotation ] ,
pages : [ PDFPage ] ,
2026-06-24 17:51:26 -07:00
isNew : Bool ,
hadUnsavedChangesBeforeCreation : Bool = false
2026-06-11 18:12:13 -07:00
) {
closeNativePopups ( on : pages )
let first = annotations . first
activeEditor = AnnotationEditorContext (
title : title ,
annotations : annotations ,
pages : pages ,
isNewAnnotation : isNew ,
2026-06-24 17:51:26 -07:00
hadUnsavedChangesBeforeCreation : hadUnsavedChangesBeforeCreation ,
2026-06-11 18:12:13 -07:00
allowsDelete : true ,
2026-06-11 22:03:43 -07:00
initialText : first . map ( AnnotationKeys . commentText ( for : ) ) ? ? " " ,
2026-06-11 18:12:13 -07:00
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 )
}
}
2026-06-11 22:03:43 -07:00
private func rootComment ( for target : AnnotationSnapshot ) -> AnnotationSnapshot ? {
guard let parentID = target . parentID else { return target }
return annotations . first { $0 . id = = parentID }
}
private func snapshot ( for annotation : PDFAnnotation ) -> AnnotationSnapshot ? {
annotations . first { $0 . annotation = = = annotation }
}
2026-06-24 17:51:26 -07:00
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 < String > {
var visibleIDs = Set < String > ( )
if showLeftSidebar , sidebarMode = = . annotations {
visibleIDs . formUnion ( annotations . map ( \ . id ) )
}
guard showCommentsSidebar else {
return visibleIDs
}
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
}
2026-06-11 22:03:43 -07:00
private func clearSidebarReplyDraft ( ) {
sidebarReplyParentID = nil
sidebarReplyTargetID = nil
sidebarReplyDraft = " "
sidebarReplyAuthor = AnnotationFactory . defaultAuthor
}
2026-06-24 17:51:26 -07:00
private var hasSidebarReplyDraft : Bool {
! sidebarReplyDraft . trimmingCharacters ( in : . whitespacesAndNewlines ) . isEmpty
}
private func clearSidebarReplyDraftIfNeeded ( deleting targetIDs : Set < String > ) {
guard sidebarReplyDraftWouldBeDiscarded ( deleting : targetIDs ) else { return }
clearSidebarReplyDraft ( )
}
private func sidebarReplyDraftWouldBeDiscarded ( deleting targetIDs : Set < String > ) -> Bool {
guard hasSidebarReplyDraft else { return false }
return sidebarReplyParentID . map ( targetIDs . contains ) = = true
|| sidebarReplyTargetID . map ( targetIDs . contains ) = = true
}
2026-06-11 22:03:43 -07:00
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
}
}
2026-06-24 17:51:26 -07:00
private func uniquePages ( _ pages : [ PDFPage ] , in document : PDFDocument ) -> [ ( page : PDFPage , index : Int ) ] {
var seenIndexes = Set < Int > ( )
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 )
}
}
2026-06-11 18:12:13 -07:00
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
}
2026-06-24 17:51:26 -07:00
@ discardableResult
private func write ( _ document : PDFDocument , to url : URL ) -> Bool {
prepareAnnotationsForExport ( in : document )
2026-06-11 18:12:13 -07:00
guard document . write ( to : url ) else {
2026-06-11 22:03:43 -07:00
hidePopupMarkersInViewer ( in : document )
2026-06-11 18:12:13 -07:00
showAlert ( title : " Save Failed " , message : " The PDF could not be written to \( url . path ) . " )
2026-06-24 17:51:26 -07:00
return false
2026-06-11 18:12:13 -07:00
}
refreshAnnotations ( )
2026-06-24 17:51:26 -07:00
hasUnsavedChanges = false
2026-06-11 18:12:13 -07:00
statusMessage = " Saved \( url . lastPathComponent ) . "
2026-06-24 17:51:26 -07:00
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 < String > ,
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
2026-06-11 18:12:13 -07:00
}
private func presentSharePicker ( for url : URL ) {
2026-06-24 17:51:26 -07:00
guard let contentView = pdfView ? . window ? . contentView ? ? NSApp . keyWindow ? . contentView else { return }
2026-06-11 18:12:13 -07:00
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 )
2026-06-24 17:51:26 -07:00
statusMessage = " Search match \( index + 1 ) of \( searchResults . count ) . "
2026-06-11 18:12:13 -07:00
}
2026-06-24 17:51:26 -07:00
private func clearSearchState ( ) {
activeSearchQuery = nil
searchText = " "
showToolbarSearch = false
clearSearchResults ( )
}
private func clearSearchResults ( ) {
activeSearchQuery = nil
searchResults = [ ]
currentSearchIndex = 0
pdfView ? . highlightedSelections = nil
}
private func clearSearchResultsForEditedQuery ( ) {
guard let activeSearchQuery ,
searchText . trimmingCharacters ( in : . whitespacesAndNewlines ) != activeSearchQuery
else {
return
}
clearSearchResults ( )
}
2026-06-11 18:34:41 -07:00
2026-06-24 17:51:26 -07:00
private func hideReplyMarkers ( in document : PDFDocument ) {
2026-06-11 18:34:41 -07:00
for pageIndex in 0. . < document . pageCount {
guard let page = document . page ( at : pageIndex ) else { continue }
2026-06-24 17:51:26 -07:00
hideReplyMarkers ( on : page )
2026-06-11 18:34:41 -07:00
}
2026-06-24 17:51:26 -07:00
}
2026-06-11 18:34:41 -07:00
2026-06-24 17:51:26 -07:00
@ discardableResult
private func hideReplyMarkers ( on page : PDFPage ) -> Bool {
var didChange = false
for annotation in page . annotations where AnnotationKeys . isReply ( annotation ) {
AnnotationFactory . hideReplyMarker ( annotation , on : page )
didChange = true
}
if didChange {
2026-06-11 18:34:41 -07:00
pdfView ? . annotationsChanged ( on : page )
}
2026-06-24 17:51:26 -07:00
return didChange
2026-06-11 18:34:41 -07:00
}
2026-06-11 22:03:43 -07:00
private func normalizePopupMarkers ( in document : PDFDocument ) {
for pageIndex in 0. . < document . pageCount {
guard let page = document . page ( at : pageIndex ) else { continue }
2026-06-24 17:51:26 -07:00
normalizePopupMarkers ( on : page )
}
}
@ discardableResult
private func normalizePopupMarkers ( on page : PDFPage ) -> Bool {
var didChange = false
for annotation in page . annotations where ! AnnotationKeys . annotation ( annotation , hasSubtype : . popup ) {
if AnnotationFactory . normalizePopupPlacement ( for : annotation , on : page ) {
didChange = true
2026-06-11 22:03:43 -07:00
}
}
2026-06-24 17:51:26 -07:00
if didChange {
2026-06-11 22:03:43 -07:00
pdfView ? . annotationsChanged ( on : page )
}
2026-06-24 17:51:26 -07:00
return didChange
2026-06-11 22:03:43 -07:00
}
2026-06-24 17:51:26 -07:00
private func prepareAnnotationsForExport ( in document : PDFDocument ) {
2026-06-11 22:03:43 -07:00
var changedPages = Set < PDFPage > ( )
for pageIndex in 0. . < document . pageCount {
guard let page = document . page ( at : pageIndex ) else { continue }
for annotation in page . annotations where ! AnnotationKeys . annotation ( annotation , hasSubtype : . popup ) {
2026-06-24 17:51:26 -07:00
if AnnotationFactory . prepareForPreviewCompatibleExport ( annotation , on : page ) {
2026-06-11 22:03:43 -07:00
changedPages . insert ( page )
}
}
}
for page in changedPages {
pdfView ? . annotationsChanged ( on : page )
}
}
private func hidePopupMarkersInViewer ( in document : PDFDocument ) {
for pageIndex in 0. . < document . pageCount {
guard let page = document . page ( at : pageIndex ) else { continue }
2026-06-24 17:51:26 -07:00
hidePopupMarkersInViewer ( on : page )
}
}
2026-06-11 22:03:43 -07:00
2026-06-24 17:51:26 -07:00
@ discardableResult
private func hidePopupMarkersInViewer ( on page : PDFPage ) -> 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
2026-06-11 22:03:43 -07:00
}
2026-06-24 17:51:26 -07:00
if detachPopupMarkerFromViewer ( for : annotation , on : page ) {
didChange = true
2026-06-11 22:03:43 -07:00
}
}
2026-06-24 17:51:26 -07:00
for popup in popupsToRemove {
page . removeAnnotation ( popup )
}
didChange = didChange || ! popupsToRemove . isEmpty
if didChange {
2026-06-11 22:03:43 -07:00
pdfView ? . annotationsChanged ( on : page )
}
2026-06-24 17:51:26 -07:00
return didChange
2026-06-11 22:03:43 -07:00
}
@ discardableResult
private func detachPopupMarkerFromViewer ( for annotation : PDFAnnotation , on page : PDFPage ) -> Bool {
AnnotationFactory . detachPopupForViewer ( from : annotation , on : page )
}
2026-06-11 18:12:13 -07:00
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 ) "
2026-06-24 17:51:26 -07:00
statusMessage = " Page \( pageIndex + 1 ) of \( pageCount ) . "
2026-06-11 18:12:13 -07:00
}
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
}
2026-06-24 17:51:26 -07:00
let visibleTarget = visibleAnnotationTarget ( for : previous )
visibleTarget . annotation . isHighlighted = false
pdfView ? . annotationsChanged ( on : visibleTarget . page )
2026-06-11 18:12:13 -07:00
}
private func clearHoveredAnnotation ( except keptID : String ? = nil ) {
guard let hoveredAnnotationID ,
2026-06-24 17:51:26 -07:00
hoveredAnnotationID != keptID
else { return }
defer {
self . hoveredAnnotationID = nil
2026-06-11 18:12:13 -07:00
}
2026-06-24 17:51:26 -07:00
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 )
2026-06-11 18:12:13 -07:00
}
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 ) "
}
}