diff --git a/.gitignore b/.gitignore index 9b04c6d..b86d3b5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ .build/ DerivedData/ dist/ +release/ +*.zip *.xcuserstate *.xcworkspace/xcuserdata/ *.xcodeproj/xcuserdata/ +*.mobileprovision +*.provisionprofile +*.p12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5aff5..aa779e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,72 @@ # Changelog -## Version 0.2 - 2026-06-18 +## Version 0.3.0 (build 4) - 2026-06-24 + +Version 0.3 is focused on making annotation work feel reliable enough for real PDF review: clearer highlights, better comment behavior, safer saving, and release packaging for the Mac App Store. + +### Highlights + +- Added a Settings window for highlight and comment colors, including opacity. +- Added drag-and-drop opening when no PDF is open. +- Highlighting selected text now creates a highlight immediately instead of opening an empty comment box. +- Pressing Return now saves comments and replies; Shift-Return inserts a new line. +- Saved text comments now remain visible in macOS Preview and Adobe Acrobat. +- Added Mac App Store packaging for bundle ID `net.akkolli.ihatepdfs`. + +### Annotation And Comment Improvements + +- Highlight and comment colors now have stronger default contrast. +- Custom highlight and comment colors keep a minimum readable opacity. +- New comment popovers focus the text box immediately, so the text cursor appears before typing. +- Clicking commented or underlined text reopens the editor more accurately. +- Clicking nearby whitespace or the line below a comment no longer opens the popover by mistake. +- Empty newly created selected-text comments and free-text notes are discarded when closed, so they do not leave behind blank annotations. +- Plain highlights and underlines can remain empty without being deleted. +- Comments imported from other PDF readers are shown even when the app-specific comment field is missing. + +### Saving, Sharing, And Document Safety + +- The app now prompts before closing, replacing, or quitting with unsaved annotation changes. +- The window uses the native macOS edited-document indicator while annotations or reply drafts are unsaved. +- Save is disabled when there is nothing to save. +- Save, Save As, and Share warn before omitting an unsent sidebar reply draft. +- Share avoids redundant save prompts when the current PDF is already saved. +- Save-before-close prompts name the file that would be overwritten. + +### Comments Sidebar + +- The comments sidebar now handles replies, filters, collapsed page groups, and search more consistently. +- Matching replies keep their parent thread visible in search results. +- Filters that hide every comment now show a clear empty state with a Clear Filters action. +- Starting a new reply no longer silently discards a draft for another comment. +- Sidebar hover and selection highlights now clear when filters, collapsed groups, or sidebar visibility hide the selected row. +- Selecting a sidebar reply scrolls to and highlights the visible parent annotation instead of a hidden reply marker. + +### Search And Navigation + +- Closing PDF search clears match highlights from the document. +- Editing the search field clears stale match highlights until the new search is submitted. +- Search now reports the current match number while stepping through results. +- Page navigation disables unavailable previous/next controls and recovers cleanly from invalid page-number entries. + +### Packaging And Release + +- The app version is now `0.3.0`, build `4`. +- Release scripts now build a v0.3 DMG filename by default. +- Added a shared release-version script so app bundle versions, DMG names, and App Store package names stay aligned. +- Added App Store sandbox entitlements for user-selected PDF read/write access. +- Added a signed Mac App Store `.pkg` build path. +- Added release QA, App Store packaging, and engineering-size documentation. + +### Tests + +- Added tests for color preference storage and minimum opacity. +- Added tests for tighter text-markup hit testing. +- Added tests for PDF drag-and-drop file selection. +- Added tests for Return versus Shift-Return commit behavior. +- Expanded PDF annotation export tests for Preview-compatible comments, popup cleanup, configured colors, replies, and imported annotations. + +## Version 0.2.0 - 2026-06-18 ### Fixed @@ -13,8 +79,8 @@ - Sidebar toolbar controls are grouped together in the leading toolbar area for better visibility. -## Version 0.1 +## Version 0.1.0 -- Initial macOS SwiftUI/PDFKit release. +- Initial native macOS SwiftUI/PDFKit release. - Local PDF opening, reading, zoom, fit width, fit page, page navigation, and search. -- Highlight, underline, selection-bound comment, free-text annotation, comments sidebar, save, Save As, and share workflows. +- Highlight, underline, selection-bound comment, free-text annotation, comments sidebar, Save, Save As, and Share workflows. diff --git a/README.md b/README.md index 70e594c..6b9c07e 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,41 @@ # I Hate PDFs -I Hate PDFs is an open-source macOS PDF reader for anyone who hates adobe. I think adobe is worth of any sentient being's disdain. - -## Status - -This app is entirely vibe coded, but will somehow still be better than adobe acrobat soon. +I Hate PDFs is a small native macOS PDF reader for local reading, highlighting, commenting, and review. It uses SwiftUI, AppKit, and PDFKit, keeps documents on your Mac, and avoids accounts, tracking, and cloud upload. Minimum supported macOS version: macOS 13 Ventura. -Supported Mac architectures: Apple Silicon and Intel, subject to the local Swift/Xcode toolchain used to build. +Supported Mac architectures: Apple Silicon and Intel. ## Latest Release -Download the v0.2 macOS DMG from the GitHub release page: +Current version: `0.3.0` build `4`. - +Download the v0.3 macOS DMG from the GitHub release page: -Use `IHatePDFs-v0.2-macos.dmg` for normal app installation. Open the DMG, then drag `I Hate PDFs.app` into `/Applications`. + -Signing status for v0.2: the DMG is ad-hoc signed, but it is not Developer ID signed or Apple-notarized yet. macOS Gatekeeper may require opening the app from Finder with Control-click, then Open, on first launch. +Use `IHatePDFs-v0.3-macos.dmg` for direct installation. Open the DMG, then move `I Hate PDFs.app` into `/Applications`. + +The direct-download DMG is separate from the Mac App Store build. The App Store package uses bundle ID `net.akkolli.ihatepdfs` and is built with the sandbox entitlements documented in `docs/APP_STORE.md`. ## Features - Open local `.pdf` files from disk. +- Drag a PDF onto the empty app window to open it. - Read with smooth PDFKit scrolling, Retina rendering, zoom, fit-to-width, fit-to-page, and page navigation. - Search selectable text PDFs from a compact toolbar control. - Start in a focused single-pane reading layout, with thumbnail and comments sidebars hidden until requested. - Remember thumbnail and comments sidebar visibility per PDF and coarse window size. -- Toggle a compact page thumbnail/sidebar inspector. -- Create selection-bound comments from highlighted PDF text. -- Create highlight annotations with anchored optional comments. -- Create underline annotations with optional comments. +- Configure highlight and comment colors, including opacity, from Settings. +- Create standalone highlights from selected text. +- Create selected-text comments and underline comments. - Create free-text annotations directly on the page. -- Click annotations in the PDF to reopen and edit the comment in place. +- Press Return to save comments and replies, or Shift-Return for a new line. +- Click commented or underlined text in the PDF to reopen and edit the comment in place. - Save annotations directly into the original PDF after an overwrite warning. - Save As a new annotated copy. - Share the annotated PDF through the native macOS share picker. -- Review annotations in a compact list with page number, type, author, date, and first comment line. -- Use an Acrobat-style comments sidebar with total count, page grouping, collapsible groups, an add-comment affordance, comment search, collapsed type/author/status filters, full text, replies, edit/delete, and click-to-navigate. - -### Download Releases - -https://github.com/akkolli/ihatepdfs/releases/tag/v0.2 - +- Review annotations in a comments sidebar with page grouping, search, filters, replies, edit/delete, and click-to-navigate. ## Build From Source @@ -82,21 +75,34 @@ Create a downloadable `.dmg`: scripts/make-dmg.sh ``` -The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs-v0.2-macos.dmg`. +The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs-v0.3-macos.dmg` by default. + +Build an App Store upload package after installing the application signing certificate, installer signing certificate, and App Store provisioning profile: + +```sh +APP_SIGNING_IDENTITY="3rd Party Mac Developer Application: Your Name (TEAMID)" \ +INSTALLER_SIGNING_IDENTITY="3rd Party Mac Developer Installer: Your Name (TEAMID)" \ +PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \ +scripts/make-app-store-pkg.sh +``` + +The App Store package is written to `dist/IHatePDFs-v0.3-macos-appstore.pkg`. More details are in `docs/APP_STORE.md`. ## Installation -Download `IHatePDFs-v0.2-macos.dmg` from the latest GitHub release, open it, and move `I Hate PDFs.app` into `/Applications`. +Download `IHatePDFs-v0.3-macos.dmg` from the latest GitHub release, open it, and move `I Hate PDFs.app` into `/Applications`. -For v0.2 and local development builds, the app is not Developer ID signed or notarized. If macOS blocks first launch, open Finder, Control-click `I Hate PDFs.app`, choose Open, then confirm. +For local direct-download builds, the app may not be Developer ID notarized. If macOS blocks first launch, open Finder, Control-click `I Hate PDFs.app`, choose Open, then confirm. ## Development The project is a Swift Package with two targets: -- `IHatePDFsCore`: PDF annotation models and factory helpers. +- `IHatePDFsCore`: PDF annotation models, annotation export helpers, hit testing, color preference logic, file selection, and keyboard policies. - `IHatePDFs`: SwiftUI macOS app, PDFKit bridge, toolbar, menus, sidebars, anchored comment popovers, opening, saving, sharing, and search. +Engineering rule: keep this a native macOS app with the smallest final bundle that still delivers the required fluidity and functionality. See `docs/ENGINEERING.md` before adding dependencies, bundled assets, PDF engines, runtimes, or broad architectural changes. + Useful checks: ```sh @@ -106,9 +112,9 @@ swift scripts/verify-sample-pdf.swift swift scripts/verify-pdf-annotations.swift ``` -The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, and popup annotation dictionaries. +The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, contents, and annotation relationship dictionaries. -Manual release QA for Preview, Acrobat Reader, and browser PDF viewers is documented in `docs/QA.md`. The macOS design review is documented in `docs/DESIGN_REVIEW.md`. +Manual release QA for Preview, Acrobat Reader, and browser PDF viewers is documented in `docs/QA.md`. App Store packaging is documented in `docs/APP_STORE.md`. ## Screenshots diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1ae4a89..25ebd0a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,30 @@ # Release Notes -## Version 0.2 +## I Hate PDFs v0.3.0 -- Fixed multi-window document state so opening a PDF with Finder Open With does not mirror it into an existing window. -- Fixed zoom commands so toolbar and menu zoom actions apply to the focused PDF window instead of another open window. -- Fixed comment entry so pressing Return saves a new comment without requiring the mouse. -- Kept the page and comments sidebar toolbar icons visible in narrow windows by grouping sidebar controls in the leading toolbar. +Version 0.3 makes I Hate PDFs much safer and more comfortable for everyday PDF review. + +### What's New + +- Settings for highlight and comment colors, including opacity. +- Drag-and-drop opening from the empty app window. +- Standalone highlights that do not open a comment editor. +- Return saves a comment or reply; Shift-Return inserts a new line. +- Better default highlight contrast. +- Mac App Store packaging for `net.akkolli.ihatepdfs`. + +### Reliability Fixes + +- Comment text now survives when saved PDFs are opened in macOS Preview and Adobe Acrobat. +- The comment editor focuses correctly when a new selected-text comment is created. +- Comment popovers now open from the actual annotated text instead of nearby whitespace. +- The app warns before unsaved annotations or reply drafts are lost. +- Search highlights clear correctly when search is closed or edited. +- The comments sidebar keeps threads, filters, replies, and selected highlights in sync. + +### Version + +- App version: `0.3.0` +- Build number: `4` +- Direct-download DMG name: `IHatePDFs-v0.3-macos.dmg` +- Mac App Store package name: `IHatePDFs-v0.3-macos-appstore.pkg` diff --git a/ROADMAP.md b/ROADMAP.md index 0c3df50..014d255 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,18 @@ - `.app` and `.dmg` build scripts. - Visual QA screenshots for empty, reading, popover, comments, and dark-mode states. -## Version 0.2 +## Shipped In Version 0.3 + +- Settings for highlight and comment colors. +- Higher-contrast default highlights and comments. +- Standalone highlights that do not open a comment editor. +- Drag-and-drop PDF opening from the empty app window. +- Return-to-save and Shift-Return-for-newline comment behavior. +- Preview-compatible exported comments for selected-text markup. +- Safer close/open/quit prompts for unsaved annotations and reply drafts. +- Mac App Store packaging path for `net.akkolli.ihatepdfs`. + +## Next - More explicit visual selection handles for the active annotation. - Better undo/redo integration for annotation edits. diff --git a/Signing/IHatePDFs-AppStore.entitlements b/Signing/IHatePDFs-AppStore.entitlements new file mode 100644 index 0000000..373b196 --- /dev/null +++ b/Signing/IHatePDFs-AppStore.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + + diff --git a/Sources/IHatePDFs/AppSettings.swift b/Sources/IHatePDFs/AppSettings.swift new file mode 100644 index 0000000..86f608d --- /dev/null +++ b/Sources/IHatePDFs/AppSettings.swift @@ -0,0 +1,121 @@ +import AppKit +import Foundation +import IHatePDFsCore +import SwiftUI + +enum AppSettings { + static let highlightColorStorageKey = "IHatePDFs.highlightColorRGBA.v1" + static let commentColorStorageKey = "IHatePDFs.commentColorRGBA.v1" + static let defaultHighlightColorStorageValue = storageString(for: AcademicAnnotationPalette.highlight) + static let defaultCommentColorStorageValue = storageString(for: AcademicAnnotationPalette.comment) + private static let minimumHighlightAlpha: CGFloat = 0.38 + private static let minimumCommentAlpha: CGFloat = 0.12 + + static var highlightColor: NSColor { + get { + highlightColor(from: UserDefaults.standard.string(forKey: highlightColorStorageKey)) + } + set { + UserDefaults.standard.set(storageString(forHighlightColor: newValue), forKey: highlightColorStorageKey) + } + } + + static var commentColor: NSColor { + get { + commentColor(from: UserDefaults.standard.string(forKey: commentColorStorageKey)) + } + set { + UserDefaults.standard.set(storageString(forCommentColor: newValue), forKey: commentColorStorageKey) + } + } + + static func highlightColor(from storageValue: String?) -> NSColor { + AnnotationColorPreference.color( + from: storageValue, + fallback: AcademicAnnotationPalette.highlight, + minimumAlpha: minimumHighlightAlpha + ) + } + + static func commentColor(from storageValue: String?) -> NSColor { + AnnotationColorPreference.color( + from: storageValue, + fallback: AcademicAnnotationPalette.comment, + minimumAlpha: minimumCommentAlpha + ) + } + + static func storageString(for color: NSColor) -> String { + AnnotationColorPreference.storageString(for: color) + } + + static func storageString(for color: Color) -> String { + storageString(for: NSColor(color)) + } + + static func storageString(forHighlightColor color: NSColor) -> String { + storageString(for: highlightColor(from: storageString(for: color))) + } + + static func storageString(forHighlightColor color: Color) -> String { + storageString(forHighlightColor: NSColor(color)) + } + + static func storageString(forCommentColor color: NSColor) -> String { + storageString(for: commentColor(from: storageString(for: color))) + } + + static func storageString(forCommentColor color: Color) -> String { + storageString(forCommentColor: NSColor(color)) + } +} + +struct SettingsView: View { + @AppStorage(AppSettings.highlightColorStorageKey) + private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue + @AppStorage(AppSettings.commentColorStorageKey) + private var storedCommentColor = AppSettings.defaultCommentColorStorageValue + + var body: some View { + Form { + Section("Annotations") { + ColorPicker( + "Highlight color", + selection: highlightColor, + supportsOpacity: true + ) + + ColorPicker( + "Comment color", + selection: commentColor, + supportsOpacity: true + ) + + Button { + storedHighlightColor = AppSettings.defaultHighlightColorStorageValue + storedCommentColor = AppSettings.defaultCommentColorStorageValue + } label: { + Label("Reset Annotation Colors", systemImage: "arrow.counterclockwise") + } + } + } + .padding(20) + .frame(width: 360) + } + + private var highlightColor: Binding { + Binding { + Color(nsColor: AppSettings.highlightColor(from: storedHighlightColor)) + } set: { newValue in + storedHighlightColor = AppSettings.storageString(forHighlightColor: newValue) + } + } + + private var commentColor: Binding { + Binding { + Color(nsColor: AppSettings.commentColor(from: storedCommentColor)) + } set: { newValue in + storedCommentColor = AppSettings.storageString(forCommentColor: newValue) + } + } +} diff --git a/Sources/IHatePDFs/AppState.swift b/Sources/IHatePDFs/AppState.swift index 0086234..60d1ef4 100644 --- a/Sources/IHatePDFs/AppState.swift +++ b/Sources/IHatePDFs/AppState.swift @@ -3,6 +3,7 @@ import Foundation import IHatePDFsCore import PDFKit import SwiftUI +import UniformTypeIdentifiers enum SidebarMode: String, CaseIterable, Identifiable { case pages @@ -85,6 +86,7 @@ struct AnnotationEditorContext: Identifiable { let annotations: [PDFAnnotation] let pages: [PDFPage] let isNewAnnotation: Bool + let hadUnsavedChangesBeforeCreation: Bool let allowsDelete: Bool let initialText: String let initialAuthor: String @@ -105,30 +107,53 @@ final class AppState: NSObject, ObservableObject { @Published var showLeftSidebar = false { didSet { persistSidebarPreferenceIfNeeded() + clearSelectedAnnotationIfHiddenBySidebarState() } } @Published var showCommentsSidebar = false { didSet { persistSidebarPreferenceIfNeeded() + if !showCommentsSidebar { + clearHoveredAnnotation() + } + clearSelectedAnnotationIfHiddenBySidebarState() } } - @Published var sidebarMode: SidebarMode = .pages - @Published var searchText = "" + @Published var sidebarMode: SidebarMode = .pages { + didSet { clearSelectedAnnotationIfHiddenBySidebarState() } + } + @Published var searchText = "" { + didSet { clearSearchResultsForEditedQuery() } + } @Published var showToolbarSearch = false @Published var searchResults: [PDFSelection] = [] + @Published var hasTextSelection = false @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 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 = [] { + didSet { clearCommentReviewHighlightsHiddenBySidebarVisibility() } + } @Published var sidebarReplyParentID: String? @Published var sidebarReplyTargetID: String? @Published var sidebarReplyDraft = "" @Published var sidebarReplyAuthor = AnnotationFactory.defaultAuthor + @Published var hasUnsavedChanges = false @Published var statusMessage = "Open a PDF to begin." private var pageObserver: NSObjectProtocol? @@ -136,6 +161,7 @@ final class AppState: NSObject, ObservableObject { private var sidebarWidthBucket: SidebarWidthBucket = .regular private var isApplyingSidebarPreference = false private var hoveredAnnotationID: String? + private var activeSearchQuery: String? var displayTitle: String { documentURL?.lastPathComponent ?? "I Hate PDFs" @@ -145,6 +171,33 @@ final class AppState: NSObject, ObservableObject { 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 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" + } + var authors: [String] { let values = Set(annotations.map(\.author).filter { !$0.isEmpty }) return ["All Authors"] + values.sorted() @@ -158,7 +211,9 @@ final class AppState: NSObject, ObservableObject { } var filteredAnnotations: [AnnotationSnapshot] { - annotations.filter { item in + let query = commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines) + + return annotations.filter { item in switch commentFilter { case .all: break @@ -180,7 +235,6 @@ final class AppState: NSObject, ObservableObject { return false } - let query = commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines) if !query.isEmpty { let haystack = [ item.contents, @@ -196,7 +250,16 @@ final class AppState: NSObject, ObservableObject { } var topLevelComments: [AnnotationSnapshot] { - filteredAnnotations.filter { !$0.isReply } + 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]] { @@ -211,6 +274,24 @@ final class AppState: NSObject, ObservableObject { 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 } @@ -232,10 +313,13 @@ final class AppState: NSObject, ObservableObject { queue: .main ) { [weak self] _ in Task { @MainActor in - guard self?.placementTool == nil else { return } - self?.statusMessage = "Selection ready for annotation." + guard let self else { return } + self.updateTextSelectionState() + guard self.placementTool == nil, self.hasTextSelection else { return } + self.statusMessage = "Selection ready for annotation." } } + updateTextSelectionState() } func updateWindowWidth(_ width: CGFloat) { @@ -259,7 +343,73 @@ final class AppState: NSObject, ObservableObject { loadDocument(from: url) } - func loadDocument(from url: 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 @@ -272,18 +422,21 @@ final class AppState: NSObject, ObservableObject { pdfView?.goToFirstPage(nil) pageText = "1" currentPageIndex = 0 - searchText = "" - showToolbarSearch = false - searchResults = [] + clearSearchState() + resetCommentReviewState() selectedAnnotationID = nil activeEditor = nil placementTool = nil + hasTextSelection = false + hasUnsavedChanges = false clearSidebarReplyDraft() refreshAnnotations() statusMessage = "Opened \(url.lastPathComponent)." } func closeDocument() { + guard confirmDiscardOrSaveUnsavedChanges(actionName: "closing this PDF") else { return } + persistSidebarPreferenceIfNeeded() document = nil documentURL = nil @@ -291,10 +444,11 @@ final class AppState: NSObject, ObservableObject { selectedAnnotationID = nil activeEditor = nil placementTool = nil + hasTextSelection = false + hasUnsavedChanges = false clearSidebarReplyDraft() - searchResults = [] - searchText = "" - showToolbarSearch = false + clearSearchState() + resetCommentReviewState() pageText = "1" currentPageIndex = 0 pdfView?.document = nil @@ -302,26 +456,74 @@ final class AppState: NSObject, ObservableObject { statusMessage = "Open a PDF to begin." } + 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 + } + func saveDocument() { - guard let document else { return } + _ = 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 { - 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 } + guard hasUnsavedChanges else { + if !discardedEmptyEditor { + statusMessage = "No unsaved changes." + } + return true + } - write(document, to: url) + 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 { - saveDocumentAs() + return saveDocumentAs(confirmReplyDraft: confirmReplyDraft) } } - func saveDocumentAs() { - guard let document else { return } + @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] @@ -329,37 +531,46 @@ final class AppState: NSObject, ObservableObject { panel.title = "Save Annotated PDF" panel.nameFieldStringValue = suggestedAnnotatedFilename() - guard panel.runModal() == .OK, let url = panel.url else { return } - write(document, to: url) + guard panel.runModal() == .OK, let url = panel.url else { return false } + guard write(document, to: url) else { return false } documentURL = url persistSidebarPreferenceIfNeeded() + return true } func shareDocument() { guard let document else { return } - guard let url = documentURL else { - saveDocumentAs() + 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 = .informational - alert.messageText = "Share Annotated PDF?" - alert.informativeText = "Save annotations to \(url.lastPathComponent) before sharing so recipients see the latest comments." + 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 Existing File") + alert.addButton(withTitle: "Share Last Saved Version") alert.addButton(withTitle: "Cancel") switch alert.runModal() { case .alertFirstButtonReturn: - preparePopupMarkersForExport(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 - } - refreshAnnotations() + guard write(document, to: url) else { return } case .alertSecondButtonReturn: + statusMessage = "Sharing last saved version; unsaved annotations remain open." break default: return @@ -370,15 +581,15 @@ final class AppState: NSObject, ObservableObject { } func addHighlight() { - addMarkup(style: .highlight, title: "Highlight Comment") + addMarkup(style: .highlight, title: "Highlight", opensEditor: false) } func addUnderline() { - addMarkup(style: .underline, title: "Underline Comment") + addMarkup(style: .underline, title: "Underline Comment", opensEditor: true) } func addComment() { - addMarkup(style: .comment, title: "Comment") + addMarkup(style: .comment, title: "Comment", opensEditor: true) } func addFreeText() { @@ -389,14 +600,23 @@ final class AppState: NSObject, ObservableObject { activeEditor = nil placementTool = .freeText + pdfView?.window?.makeFirstResponder(pdfView) statusMessage = "Click on the page to place free text." } + func cancelPlacementTool() { + guard placementTool != nil else { return } + + placementTool = nil + statusMessage = "Free text placement canceled." + } + func placePendingAnnotation(on page: PDFPage, near point: CGPoint) { guard let placementTool else { return } let insertion: AnnotationInsertion let title: String + let hadUnsavedChangesBeforeCreation = hasUnsavedChanges switch placementTool { case .freeText: @@ -411,12 +631,13 @@ final class AppState: NSObject, ObservableObject { self.placementTool = nil add(insertion) - refreshAnnotations() + refreshAnnotations(on: [page]) openEditor( title: title, annotations: [insertion.annotation], pages: [page], - isNew: true + isNew: true, + hadUnsavedChangesBeforeCreation: hadUnsavedChangesBeforeCreation ) } @@ -433,14 +654,27 @@ final class AppState: NSObject, ObservableObject { return } + 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 + } + activeEditor = nil showCommentsSidebar = true sidebarReplyParentID = root.id sidebarReplyTargetID = target.id sidebarReplyDraft = "" sidebarReplyAuthor = AnnotationFactory.defaultAuthor - selectedAnnotationID = target.id - statusMessage = "Replying to \(target.author)." + select(target, statusMessage: "Replying to \(target.author).") } func cancelSidebarReply() { @@ -473,7 +707,7 @@ final class AppState: NSObject, ObservableObject { ) add(insertion) clearSidebarReplyDraft() - refreshAnnotations() + refreshAnnotations(on: [parent.page]) if let reply = annotations.first(where: { $0.annotation === insertion.annotation }) { selectedAnnotationID = reply.id @@ -487,7 +721,7 @@ final class AppState: NSObject, ObservableObject { author: String ) { updateAnnotations(in: context, text: text, author: author) - refreshAnnotations() + refreshAnnotations(on: context.pages) guard let annotation = context.primaryAnnotation, let item = snapshot(for: annotation) @@ -503,6 +737,7 @@ final class AppState: NSObject, ObservableObject { } func edit(_ item: AnnotationSnapshot) { + select(item, statusMessage: "Editing \(item.kind.displayName.lowercased()) on page \(item.pageLabel).") openEditor( title: item.kind == .freeText ? "Edit Free Text" : "Edit Comment", annotations: [item.annotation], @@ -516,6 +751,14 @@ final class AppState: NSObject, ObservableObject { 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) @@ -527,17 +770,15 @@ final class AppState: NSObject, ObservableObject { if hoveredAnnotationID.map(targetIDs.contains) == true { hoveredAnnotationID = nil } - if sidebarReplyParentID.map(targetIDs.contains) == true - || sidebarReplyTargetID.map(targetIDs.contains) == true { - clearSidebarReplyDraft() - } + clearSidebarReplyDraftIfNeeded(deleting: targetIDs) activeEditor = nil - refreshAnnotations() + 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() @@ -551,8 +792,9 @@ final class AppState: NSObject, ObservableObject { popup.modificationDate = date } + hasUnsavedChanges = true pdfView?.annotationsChanged(on: item.page) - refreshAnnotations() + refreshAnnotations(on: [item.page]) statusMessage = isReviewed ? "Marked as not reviewed." : "Marked as reviewed." } @@ -561,8 +803,17 @@ final class AppState: NSObject, ObservableObject { 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() + refreshAnnotations(on: context.pages) activeEditor = nil statusMessage = "Comment saved." } @@ -574,45 +825,89 @@ final class AppState: NSObject, ObservableObject { ) { guard activeEditor?.id == context.id else { return } updateAnnotations(in: context, text: text, author: author) - refreshAnnotations() + refreshAnnotations(on: context.pages) } 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]) + 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 - selectedAnnotationID = nil - refreshAnnotations() - statusMessage = "Annotation deleted." + 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).") + } + + private func select(_ item: AnnotationSnapshot, statusMessage message: String) { clearHoveredAnnotation() clearHighlightedAnnotation() + let visibleTarget = visibleAnnotationTarget(for: item) + 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)." + 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 - item.annotation.isHighlighted = true - pdfView?.annotationsChanged(on: item.page) + visibleTarget.annotation.isHighlighted = true + pdfView?.annotationsChanged(on: visibleTarget.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) + guard !isSelectedVisibleTarget(visibleTarget) else { return } + visibleTarget.annotation.isHighlighted = false + pdfView?.annotationsChanged(on: visibleTarget.page) } func openAnnotationFromPDF(_ annotation: PDFAnnotation, page: PDFPage) { @@ -638,12 +933,36 @@ final class AppState: NSObject, ObservableObject { } } - func refreshAnnotations() { + 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) @@ -655,8 +974,7 @@ final class AppState: NSObject, ObservableObject { guard let document else { return } let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { - searchResults = [] - pdfView?.highlightedSelections = nil + clearSearchResults() statusMessage = "Search cleared." return } @@ -666,10 +984,16 @@ final class AppState: NSObject, ObservableObject { result.color = NSColor.findHighlightColor.withAlphaComponent(0.45) } searchResults = results - pdfView?.highlightedSelections = results currentSearchIndex = 0 + activeSearchQuery = query + guard !results.isEmpty else { + pdfView?.highlightedSelections = nil + statusMessage = "No matches for \(query)." + return + } + + pdfView?.highlightedSelections = results goToSearchResult(at: currentSearchIndex) - statusMessage = results.isEmpty ? "No matches for \(query)." : "\(results.count) search matches." } func showSearch() { @@ -685,11 +1009,8 @@ final class AppState: NSObject, ObservableObject { } func hideSearch() { - showToolbarSearch = false - if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - searchResults = [] - pdfView?.highlightedSelections = nil - } + clearSearchState() + statusMessage = "Search closed." } func nextSearchResult() { @@ -735,13 +1056,26 @@ final class AppState: NSObject, ObservableObject { } 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 { + 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 } @@ -753,6 +1087,7 @@ final class AppState: NSObject, ObservableObject { 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 } @@ -767,6 +1102,7 @@ final class AppState: NSObject, ObservableObject { let page = document.page(at: index + 1) else { updateCurrentPageState() + statusMessage = "Already on the last page." return } @@ -781,7 +1117,11 @@ final class AppState: NSObject, ObservableObject { NSApp.keyWindow?.miniaturize(nil) } - private func addMarkup(style: MarkupAnnotationStyle, title: String) { + 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." @@ -793,23 +1133,33 @@ final class AppState: NSObject, ObservableObject { from: selection, style: style, comment: "", - author: AnnotationFactory.defaultAuthor + 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() - refreshAnnotations() + 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 + isNew: true, + hadUnsavedChangesBeforeCreation: hadUnsavedChangesBeforeCreation ) } @@ -819,9 +1169,22 @@ final class AppState: NSObject, ObservableObject { 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, @@ -840,10 +1203,44 @@ final class AppState: NSObject, ObservableObject { author: authorValue ) detachPopupMarkerFromViewer(for: annotation, 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 } @@ -858,6 +1255,7 @@ final class AppState: NSObject, ObservableObject { } page.removeAnnotation(annotation) + hasUnsavedChanges = true pdfView?.annotationsChanged(on: page) } @@ -865,7 +1263,8 @@ final class AppState: NSObject, ObservableObject { title: String, annotations: [PDFAnnotation], pages: [PDFPage], - isNew: Bool + isNew: Bool, + hadUnsavedChangesBeforeCreation: Bool = false ) { closeNativePopups(on: pages) let first = annotations.first @@ -874,6 +1273,7 @@ final class AppState: NSObject, ObservableObject { annotations: annotations, pages: pages, isNewAnnotation: isNew, + hadUnsavedChangesBeforeCreation: hadUnsavedChangesBeforeCreation, allowsDelete: true, initialText: first.map(AnnotationKeys.commentText(for:)) ?? "", initialAuthor: first?.userName ?? AnnotationFactory.defaultAuthor @@ -901,6 +1301,75 @@ final class AppState: NSObject, ObservableObject { 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, 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 + } + private func clearSidebarReplyDraft() { sidebarReplyParentID = nil sidebarReplyTargetID = nil @@ -908,6 +1377,21 @@ final class AppState: NSObject, ObservableObject { 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 } @@ -924,6 +1408,18 @@ final class AppState: NSObject, ObservableObject { } } + 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 @@ -939,19 +1435,102 @@ final class AppState: NSObject, ObservableObject { view.pageShadowsEnabled = true } - private func write(_ document: PDFDocument, to url: URL) { - preparePopupMarkersForExport(in: document) + @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 + 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 = NSApp.keyWindow?.contentView else { return } + guard let contentView = pdfView?.window?.contentView ?? NSApp.keyWindow?.contentView else { return } let anchor = NSRect( x: contentView.bounds.maxX - 24, @@ -974,71 +1553,87 @@ final class AppState: NSObject, ObservableObject { 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 + } + + private func clearSearchResultsForEditedQuery() { + guard let activeSearchQuery, + searchText.trimmingCharacters(in: .whitespacesAndNewlines) != activeSearchQuery + else { + return + } + + clearSearchResults() } private func hideReplyMarkers(in document: PDFDocument) { - var changedPages = Set() - for pageIndex in 0.. Bool { + var didChange = false + + for annotation in page.annotations where AnnotationKeys.isReply(annotation) { + AnnotationFactory.hideReplyMarker(annotation, on: page) + didChange = true } - for page in changedPages { + if didChange { pdfView?.annotationsChanged(on: page) } + + return didChange } private func normalizePopupMarkers(in document: PDFDocument) { - var changedPages = Set() - 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..() - for pageIndex in 0.. Bool { + var didChange = false + var popupsToRemove: [PDFAnnotation] = [] - if detachPopupMarkerFromViewer(for: annotation, on: page) { - changedPages.insert(page) - } + for annotation in page.annotations { + if AnnotationKeys.annotation(annotation, hasSubtype: .popup) { + annotation.isOpen = false + annotation.shouldDisplay = false + annotation.shouldPrint = false + popupsToRemove.append(annotation) + continue } - guard !popupsToRemove.isEmpty else { continue } - for popup in popupsToRemove { - page.removeAnnotation(popup) + if detachPopupMarkerFromViewer(for: annotation, on: page) { + didChange = true } - changedPages.insert(page) } - for page in changedPages { + for popup in popupsToRemove { + page.removeAnnotation(popup) + } + + didChange = didChange || !popupsToRemove.isEmpty + if didChange { pdfView?.annotationsChanged(on: page) } + + return didChange } @discardableResult @@ -1102,6 +1702,7 @@ final class AppState: NSObject, ObservableObject { currentPageIndex = pageIndex pageText = "\(pageIndex + 1)" + statusMessage = "Page \(pageIndex + 1) of \(pageCount)." } private func updateCurrentPageState() { @@ -1118,22 +1719,29 @@ final class AppState: NSObject, ObservableObject { else { return } - previous.annotation.isHighlighted = false - pdfView?.annotationsChanged(on: previous.page) + + 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, - hoveredAnnotationID != selectedAnnotationID, - let previous = annotations.first(where: { $0.id == hoveredAnnotationID }) - else { - return + hoveredAnnotationID != keptID + else { return } + + defer { + self.hoveredAnnotationID = nil } - previous.annotation.isHighlighted = false - pdfView?.annotationsChanged(on: previous.page) - 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) { diff --git a/Sources/IHatePDFs/IHatePDFsApp.swift b/Sources/IHatePDFs/IHatePDFsApp.swift index e548783..c2147a7 100644 --- a/Sources/IHatePDFs/IHatePDFsApp.swift +++ b/Sources/IHatePDFs/IHatePDFsApp.swift @@ -3,6 +3,8 @@ import SwiftUI @main struct IHatePDFsApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + var body: some Scene { WindowGroup { AppWindowRoot() @@ -11,6 +13,71 @@ struct IHatePDFsApp: App { .commands { AppCommands() } + + Settings { + SettingsView() + } + } +} + +@MainActor +private final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + AppStateRegistry.shared.confirmApplicationShouldTerminate() + ? .terminateNow + : .terminateCancel + } +} + +@MainActor +private final class AppStateRegistry { + static let shared = AppStateRegistry() + + private var appStates: [WeakAppState] = [] + private(set) var isTerminationApproved = false + + func register(_ appState: AppState) { + prune() + + guard !appStates.contains(where: { $0.value === appState }) else { + return + } + + appStates.append(WeakAppState(appState)) + } + + func unregister(_ appState: AppState) { + appStates.removeAll { $0.value == nil || $0.value === appState } + } + + func confirmApplicationShouldTerminate() -> Bool { + prune() + + for appState in appStates.compactMap(\.value) { + guard appState.confirmApplicationQuit() else { + cancelTerminationApproval() + return false + } + } + + isTerminationApproved = true + return true + } + + func cancelTerminationApproval() { + isTerminationApproved = false + } + + private func prune() { + appStates.removeAll { $0.value == nil } + } +} + +private final class WeakAppState { + weak var value: AppState? + + init(_ value: AppState) { + self.value = value } } @@ -21,9 +88,130 @@ private struct AppWindowRoot: View { MainView() .environmentObject(appState) .focusedObject(appState) + .background(WindowCloseGuard(appState: appState)) .onOpenURL { url in appState.loadDocument(from: url) } + .onAppear { + AppStateRegistry.shared.register(appState) + } + .onDisappear { + AppStateRegistry.shared.unregister(appState) + } + } +} + +private struct WindowCloseGuard: NSViewRepresentable { + @ObservedObject var appState: AppState + + func makeCoordinator() -> Coordinator { + Coordinator(appState: appState) + } + + func makeNSView(context: Context) -> WindowCloseGuardView { + let view = WindowCloseGuardView() + view.onWindowChange = { [weak coordinator = context.coordinator] window in + coordinator?.attach(to: window) + } + return view + } + + func updateNSView(_ view: WindowCloseGuardView, context: Context) { + context.coordinator.appState = appState + context.coordinator.updateDocumentState() + view.onWindowChange = { [weak coordinator = context.coordinator] window in + coordinator?.attach(to: window) + } + view.reportWindow() + } + + @MainActor + final class Coordinator: NSObject, NSWindowDelegate { + weak var appState: AppState? + private weak var window: NSWindow? + private weak var previousDelegate: NSWindowDelegate? + + init(appState: AppState) { + self.appState = appState + } + + func attach(to window: NSWindow?) { + guard self.window !== window else { return } + + if let oldWindow = self.window, oldWindow.delegate === self { + oldWindow.delegate = previousDelegate + } + + self.window = window + previousDelegate = window?.delegate + + if window?.delegate !== self { + window?.delegate = self + } + + updateDocumentState() + } + + func updateDocumentState() { + guard let window else { return } + + let representedURL = appState?.documentURL + if window.representedURL != representedURL { + window.representedURL = representedURL + } + + let isDocumentEdited = appState?.hasUnsavedWork == true + if window.isDocumentEdited != isDocumentEdited { + window.isDocumentEdited = isDocumentEdited + } + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + if previousDelegate?.windowShouldClose?(sender) == false { + AppStateRegistry.shared.cancelTerminationApproval() + return false + } + + if AppStateRegistry.shared.isTerminationApproved { + return true + } + + return appState?.confirmDocumentWindowClose() ?? true + } + + func windowWillClose(_ notification: Notification) { + previousDelegate?.windowWillClose?(notification) + + if window?.delegate === self { + window?.delegate = previousDelegate + } + window = nil + previousDelegate = nil + } + + deinit { + MainActor.assumeIsolated { + if window?.delegate === self { + window?.delegate = previousDelegate + } + } + } + } +} + +private final class WindowCloseGuardView: NSView { + var onWindowChange: ((NSWindow?) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + reportWindow() + } + + func reportWindow() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + onWindowChange?(window) + } } } @@ -34,6 +222,18 @@ private struct AppCommands: Commands { appState?.document != nil } + private var hasTextSelection: Bool { + appState?.hasTextSelection == true + } + + private var canSaveDocument: Bool { + appState?.canSaveDocument == true + } + + private var saveHelpText: String { + appState?.saveHelpText ?? "Open a PDF before saving." + } + var body: some Commands { CommandGroup(replacing: .newItem) { Button("Open...") { @@ -46,7 +246,8 @@ private struct AppCommands: Commands { appState?.saveDocument() } .keyboardShortcut("s") - .disabled(!hasDocument) + .disabled(!canSaveDocument) + .help(saveHelpText) Button("Save As...") { appState?.saveDocumentAs() @@ -62,6 +263,12 @@ private struct AppCommands: Commands { Divider() + Button("Settings...") { + openSettingsWindow() + } + + Divider() + Button("Close PDF") { appState?.closeDocument() } @@ -140,19 +347,19 @@ private struct AppCommands: Commands { appState?.addHighlight() } .keyboardShortcut("h", modifiers: [.command, .shift]) - .disabled(!hasDocument) + .disabled(!hasDocument || !hasTextSelection) Button("Underline Selection") { appState?.addUnderline() } .keyboardShortcut("u", modifiers: [.command, .shift]) - .disabled(!hasDocument) + .disabled(!hasDocument || !hasTextSelection) Button("Comment on Selection") { appState?.addComment() } .keyboardShortcut("n", modifiers: [.command, .shift]) - .disabled(!hasDocument) + .disabled(!hasDocument || !hasTextSelection) Button("Add Free Text") { appState?.addFreeText() @@ -175,4 +382,10 @@ private struct AppCommands: Commands { .disabled(appState == nil) } } + + private func openSettingsWindow() { + if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) { + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } + } } diff --git a/Sources/IHatePDFs/MainView.swift b/Sources/IHatePDFs/MainView.swift index 6b23f8a..fd5e13d 100644 --- a/Sources/IHatePDFs/MainView.swift +++ b/Sources/IHatePDFs/MainView.swift @@ -1,5 +1,6 @@ import IHatePDFsCore import SwiftUI +import UniformTypeIdentifiers struct MainView: View { @EnvironmentObject private var appState: AppState @@ -56,12 +57,13 @@ private struct PDFReaderView: View { private struct EmptyDocumentView: View { @EnvironmentObject private var appState: AppState + @State private var isDropTargeted = false var body: some View { VStack(spacing: 16) { - Image(systemName: "doc.richtext") + Image(systemName: isDropTargeted ? "tray.and.arrow.down" : "doc.richtext") .font(.system(size: 48, weight: .regular)) - .foregroundStyle(.secondary) + .foregroundColor(isDropTargeted ? .accentColor : .secondary) Text("Open a PDF") .font(.title2) @@ -82,6 +84,20 @@ private struct EmptyDocumentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke( + isDropTargeted ? Color.accentColor : Color.clear, + style: StrokeStyle(lineWidth: 2, dash: [8, 6]) + ) + .padding(18) + } + .onDrop( + of: [UTType.fileURL.identifier], + isTargeted: $isDropTargeted + ) { providers in + appState.openDroppedDocument(from: providers) + } } } @@ -97,6 +113,9 @@ private struct StatusBarView: View { Spacer() if appState.document != nil { + if appState.hasUnsentSidebarReplyDraft { + Text("Reply draft") + } Text("\(appState.annotations.count) annotations") Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))") } @@ -146,7 +165,7 @@ private struct ReaderToolbar: ToolbarContent { } label: { Label("Previous Page", systemImage: "chevron.up") } - .disabled(appState.document == nil) + .disabled(!appState.canGoToPreviousPage) .help("Previous Page") TextField("Page", text: $appState.pageText) @@ -165,7 +184,7 @@ private struct ReaderToolbar: ToolbarContent { } label: { Label("Next Page", systemImage: "chevron.down") } - .disabled(appState.document == nil) + .disabled(!appState.canGoToNextPage) .help("Next Page") } @@ -225,7 +244,7 @@ private struct ReaderToolbar: ToolbarContent { } label: { Label("Highlight", systemImage: "highlighter") } - .disabled(appState.document == nil) + .disabled(appState.document == nil || !appState.hasTextSelection) .help("Highlight Selection") Button { @@ -233,7 +252,7 @@ private struct ReaderToolbar: ToolbarContent { } label: { Label("Underline", systemImage: "underline") } - .disabled(appState.document == nil) + .disabled(appState.document == nil || !appState.hasTextSelection) .help("Underline Selection") Button { @@ -243,7 +262,7 @@ private struct ReaderToolbar: ToolbarContent { } .accessibilityLabel("Comment on Selection") .help("Comment on Selection") - .disabled(appState.document == nil) + .disabled(appState.document == nil || !appState.hasTextSelection) } ToolbarItemGroup { @@ -286,8 +305,8 @@ private struct ReaderToolbar: ToolbarContent { } label: { Label("Save", systemImage: "square.and.arrow.down") } - .disabled(appState.document == nil) - .help("Save PDF") + .disabled(!appState.canSaveDocument) + .help(appState.saveHelpText) Button { appState.shareDocument() diff --git a/Sources/IHatePDFs/PDFKitRepresentedView.swift b/Sources/IHatePDFs/PDFKitRepresentedView.swift index b382bb1..bc17ce2 100644 --- a/Sources/IHatePDFs/PDFKitRepresentedView.swift +++ b/Sources/IHatePDFs/PDFKitRepresentedView.swift @@ -6,6 +6,7 @@ import SwiftUI final class AcademicPDFView: PDFView { var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)? var onPlacementClick: ((PDFPage, CGPoint) -> Void)? + var onCancelPlacement: (() -> Void)? var onSelectionComment: (() -> Void)? var onPreviousPageKey: (() -> Void)? var onNextPageKey: (() -> Void)? @@ -91,6 +92,11 @@ final class AcademicPDFView: PDFView { } override func keyDown(with event: NSEvent) { + if event.keyCode == 53, placementTool != nil { + onCancelPlacement?() + return + } + let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift] guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else { super.keyDown(with: event) @@ -156,24 +162,15 @@ final class AcademicPDFView: PDFView { private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? { if let direct = page.annotation(at: point), - let editable = editableParent(for: direct, on: page) { + let editable = editableParent(for: direct, on: page), + isInteractionPoint(point, on: direct, editable: editable) { return editable } for annotation in page.annotations.reversed() { guard let editable = editableParent(for: annotation, on: page) else { continue } - if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) { - return editable - } - - if let popup = editable.popup, - popup.bounds.insetBy(dx: -10, dy: -10).contains(point) { - return editable - } - - if isTextMarkup(editable), - textMarkupInteractionBounds(for: editable, on: page).contains(point) { + if isInteractionPoint(point, on: annotation, editable: editable) { return editable } } @@ -181,6 +178,31 @@ final class AcademicPDFView: PDFView { return nil } + private func isInteractionPoint( + _ point: CGPoint, + on annotation: PDFAnnotation, + editable: PDFAnnotation + ) -> Bool { + if AnnotationKeys.annotation(annotation, hasSubtype: .popup) { + return annotation.bounds.insetBy(dx: -10, dy: -10).contains(point) + } + + if isTextMarkup(editable) { + return AnnotationHitTesting.containsTextMarkupPoint(point, in: editable) + } + + if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) { + return true + } + + if let popup = editable.popup, + popup.bounds.insetBy(dx: -10, dy: -10).contains(point) { + return true + } + + return false + } + private func editableParent(for annotation: PDFAnnotation, on page: PDFPage) -> PDFAnnotation? { if let owner = popupOwner(for: annotation, on: page) { return isEditableAcademicAnnotation(owner) ? owner : nil @@ -228,20 +250,6 @@ final class AcademicPDFView: PDFView { || AnnotationKeys.annotation(annotation, hasSubtype: .underline) } - private func textMarkupInteractionBounds( - for annotation: PDFAnnotation, - on page: PDFPage - ) -> CGRect { - var bounds = annotation.bounds.insetBy(dx: -48, dy: -48) - - if let popup = annotation.popup { - bounds = bounds.union(popup.bounds.insetBy(dx: -16, dy: -16)) - } - - let pageBounds = page.bounds(for: displayBox).insetBy(dx: -64, dy: -64) - return bounds.intersection(pageBounds) - } - private func closeNativePopups(on page: PDFPage) { for annotation in page.annotations { if AnnotationKeys.annotation(annotation, hasSubtype: .popup) { @@ -253,8 +261,16 @@ final class AcademicPDFView: PDFView { } private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool { - AnnotationKeys.annotation(annotation, hasSubtype: .highlight) - || AnnotationKeys.annotation(annotation, hasSubtype: .underline) + if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) { + let isSelectionComment = annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String + == AnnotationKeys.appKindComment + let hasCommentText = !AnnotationKeys.commentText(for: annotation) + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + return isSelectionComment || hasCommentText + } + + return AnnotationKeys.annotation(annotation, hasSubtype: .underline) || AnnotationKeys.annotation(annotation, hasSubtype: .text) || AnnotationKeys.annotation(annotation, hasSubtype: .freeText) } @@ -279,6 +295,11 @@ struct PDFKitRepresentedView: NSViewRepresentable { appState.placePendingAnnotation(on: page, near: point) } } + view.onCancelPlacement = { + Task { @MainActor in + appState.cancelPlacementTool() + } + } view.onSelectionComment = { Task { @MainActor in appState.addComment() @@ -364,6 +385,45 @@ struct PDFKitRepresentedView: NSViewRepresentable { of: view, preferredEdge: preferredEdge(for: anchor, in: view) ) + focusCommentEditor(in: controller.view) + } + + private func focusCommentEditor(in view: NSView) { + Self.focusFirstTextView(in: view) + + DispatchQueue.main.async { [weak view] in + guard let view else { return } + Self.focusFirstTextView(in: view) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak view] in + guard let view else { return } + Self.focusFirstTextView(in: view) + } + } + + private static func focusFirstTextView(in view: NSView) { + view.layoutSubtreeIfNeeded() + guard let textView = firstTextView(in: view) else { return } + + textView.window?.makeFirstResponder(textView) + textView.setSelectedRange(NSRange(location: textView.string.utf16.count, length: 0)) + textView.insertionPointColor = .labelColor + textView.needsDisplay = true + } + + private static func firstTextView(in view: NSView) -> NSTextView? { + if let textView = view as? NSTextView { + return textView + } + + for subview in view.subviews { + if let textView = firstTextView(in: subview) { + return textView + } + } + + return nil } private func dismissCurrent(commit: Bool) { diff --git a/Sources/IHatePDFs/ReturnKeyCommitMonitor.swift b/Sources/IHatePDFs/ReturnKeyCommitMonitor.swift index 50136f0..c19f395 100644 --- a/Sources/IHatePDFs/ReturnKeyCommitMonitor.swift +++ b/Sources/IHatePDFs/ReturnKeyCommitMonitor.swift @@ -1,21 +1,33 @@ import AppKit +import IHatePDFsCore import SwiftUI extension View { - func commitOnPlainReturn(_ action: @escaping () -> Void) -> some View { - modifier(ReturnKeyCommitMonitor(action: action)) + func commitOnPlainReturn(isEnabled: Bool = true, _ action: @escaping () -> Void) -> some View { + modifier(ReturnKeyCommitMonitor(isEnabled: isEnabled, action: action)) } } private struct ReturnKeyCommitMonitor: ViewModifier { + let isEnabled: Bool let action: () -> Void @State private var monitor: Any? + @State private var eventWindowBox = EventWindowBox() func body(content: Content) -> some View { content + .background( + EventWindowReader { window in + eventWindowBox.windowID = window.map(ObjectIdentifier.init) + } + ) .onAppear { + eventWindowBox.isEnabled = isEnabled installMonitor() } + .onChange(of: isEnabled) { value in + eventWindowBox.isEnabled = value + } .onDisappear { removeMonitor() } @@ -23,8 +35,15 @@ private struct ReturnKeyCommitMonitor: ViewModifier { private func installMonitor() { removeMonitor() + let eventWindowBox = eventWindowBox monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - guard isPlainReturn(event) else { return event } + guard eventWindowBox.isEnabled, + shouldCommit(event), + eventWindowBox.windowID.map({ event.window.map(ObjectIdentifier.init) == $0 }) == true + else { + return event + } + action() return nil } @@ -36,10 +55,52 @@ private struct ReturnKeyCommitMonitor: ViewModifier { self.monitor = nil } - private func isPlainReturn(_ event: NSEvent) -> Bool { - guard event.keyCode == 36 || event.keyCode == 76 else { return false } - - let multilineModifiers: NSEvent.ModifierFlags = [.shift, .option, .command, .control] - return event.modifierFlags.intersection(multilineModifiers).isEmpty + private func shouldCommit(_ event: NSEvent) -> Bool { + let textView = event.window?.firstResponder as? NSTextView + let isEditableMultilineText = textView?.isEditable == true && textView?.isFieldEditor == false + return ReturnKeyCommitPolicy.shouldCommit( + keyCode: UInt16(event.keyCode), + shift: event.modifierFlags.contains(.shift), + option: event.modifierFlags.contains(.option), + command: event.modifierFlags.contains(.command), + control: event.modifierFlags.contains(.control), + isEditableMultilineText: isEditableMultilineText + ) + } +} + +private final class EventWindowBox { + var windowID: ObjectIdentifier? + var isEnabled = true +} + +private struct EventWindowReader: NSViewRepresentable { + let onWindowChange: (NSWindow?) -> Void + + func makeNSView(context: Context) -> WindowReportingView { + let view = WindowReportingView() + view.onWindowChange = onWindowChange + return view + } + + func updateNSView(_ view: WindowReportingView, context: Context) { + view.onWindowChange = onWindowChange + view.reportWindow() + } +} + +private final class WindowReportingView: NSView { + var onWindowChange: ((NSWindow?) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + reportWindow() + } + + func reportWindow() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + onWindowChange?(window) + } } } diff --git a/Sources/IHatePDFs/SidebarViews.swift b/Sources/IHatePDFs/SidebarViews.swift index 600e53f..caf4be8 100644 --- a/Sources/IHatePDFs/SidebarViews.swift +++ b/Sources/IHatePDFs/SidebarViews.swift @@ -98,6 +98,7 @@ struct CommentsReviewSidebar: View { @State private var showsSearch = false @State private var showsFilters = false @State private var showsAdvancedFilters = false + @FocusState private var isCommentSearchFocused: Bool private var groupedComments: [(pageIndex: Int, items: [AnnotationSnapshot])] { let grouped = Dictionary(grouping: appState.topLevelComments, by: \.pageIndex) @@ -106,6 +107,27 @@ struct CommentsReviewSidebar: View { .sorted { $0.pageIndex < $1.pageIndex } } + private var visibleCommentCount: Int { + appState.topLevelComments.reduce(0) { partial, item in + partial + 1 + (appState.repliesByParent[item.id]?.count ?? 0) + } + } + + private var isFilteringComments: Bool { + hasActiveCommentSearch || hasActiveCommentFilters + } + + private var hasActiveCommentSearch: Bool { + !appState.commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var hasActiveCommentFilters: Bool { + appState.commentFilter != .all + || appState.selectedKindFilter != nil + || appState.selectedAuthorFilter != "All Authors" + || appState.selectedStatusFilter != ReviewState.allStatuses + } + var body: some View { VStack(spacing: 0) { header @@ -132,7 +154,7 @@ struct CommentsReviewSidebar: View { .font(.headline) .lineLimit(1) - Text("\(appState.annotations.count)") + Text("\(visibleCommentCount)") .font(.headline.monospacedDigit()) .foregroundStyle(.secondary) .lineLimit(1) @@ -141,18 +163,31 @@ struct CommentsReviewSidebar: View { Button { showsSearch.toggle() + if showsSearch { + focusCommentSearch() + } else { + isCommentSearchFocused = false + } } label: { - Label("Search Comments", systemImage: showsSearch ? "magnifyingglass.circle.fill" : "magnifyingglass") + Label( + "Search Comments", + systemImage: (showsSearch || hasActiveCommentSearch) ? "magnifyingglass.circle.fill" : "magnifyingglass" + ) } .labelStyle(.iconOnly) + .foregroundStyle(hasActiveCommentSearch ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme)) .help("Search Comments") Button { showsFilters.toggle() } label: { - Label("Filter Comments", systemImage: showsFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + Label( + "Filter Comments", + systemImage: (showsFilters || hasActiveCommentFilters) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle" + ) } .labelStyle(.iconOnly) + .foregroundStyle(hasActiveCommentFilters ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme)) .help("Filter Comments") } .padding(.horizontal, 10) @@ -184,6 +219,7 @@ struct CommentsReviewSidebar: View { } } .buttonStyle(.plain) + .disabled(!appState.hasTextSelection) .padding(.horizontal, 10) .padding(.vertical, 8) .help("Select text, then add a comment") @@ -194,6 +230,10 @@ struct CommentsReviewSidebar: View { if showsSearch { TextField("Search comments", text: $appState.commentSearchText) .textFieldStyle(.roundedBorder) + .focused($isCommentSearchFocused) + .onAppear { + focusCommentSearch() + } } if showsFilters { @@ -238,23 +278,75 @@ struct CommentsReviewSidebar: View { .padding(10) } + private func focusCommentSearch() { + DispatchQueue.main.async { + isCommentSearchFocused = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isCommentSearchFocused = true + } + } + private var commentList: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(groupedComments, id: \.pageIndex) { group in - PageCommentGroup( - pageIndex: group.pageIndex, - items: group.items, - repliesByParent: appState.repliesByParent, - showsPageHeader: appState.pageCount > 1 - ) + Group { + if groupedComments.isEmpty { + CommentsEmptyState(isFiltering: isFilteringComments) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(groupedComments, id: \.pageIndex) { group in + PageCommentGroup( + pageIndex: group.pageIndex, + items: group.items, + repliesByParent: appState.repliesByParent, + showsPageHeader: appState.pageCount > 1, + isFiltering: isFilteringComments + ) + } + } + .padding(.vertical, 4) } } - .padding(.vertical, 4) } } } +private struct CommentsEmptyState: View { + @EnvironmentObject private var appState: AppState + @Environment(\.colorScheme) private var colorScheme + let isFiltering: Bool + + var body: some View { + VStack(spacing: 9) { + Image(systemName: isFiltering ? "line.3.horizontal.decrease.circle" : "text.bubble") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(InterfacePalette.quietText(for: colorScheme)) + + Text(isFiltering ? "No matching comments" : "No comments yet") + .font(.callout.weight(.semibold)) + .foregroundStyle(InterfacePalette.primaryText(for: colorScheme)) + + Text(isFiltering ? "Adjust the search or filters to show more comments." : "Select text in the PDF, then add a comment.") + .font(.caption) + .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + if isFiltering { + Button { + appState.clearCommentFilters() + } label: { + Label("Clear Filters", systemImage: "xmark.circle") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(18) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + private struct PageCommentGroup: View { @EnvironmentObject private var appState: AppState @Environment(\.colorScheme) private var colorScheme @@ -262,40 +354,37 @@ private struct PageCommentGroup: View { let items: [AnnotationSnapshot] let repliesByParent: [String: [AnnotationSnapshot]] let showsPageHeader: Bool + let isFiltering: Bool private var isCollapsed: Bool { - showsPageHeader && appState.collapsedPageIndexes.contains(pageIndex) + showsPageHeader && !isFiltering && appState.collapsedPageIndexes.contains(pageIndex) + } + + private var visibleItemCount: Int { + items.reduce(0) { partial, item in + partial + 1 + (repliesByParent[item.id]?.count ?? 0) + } } var body: some View { VStack(alignment: .leading, spacing: 0) { if showsPageHeader { - Button { - if isCollapsed { - appState.collapsedPageIndexes.remove(pageIndex) - } else { - appState.collapsedPageIndexes.insert(pageIndex) + if isFiltering { + pageHeader + .help("Filtered results are expanded") + } else { + Button { + if isCollapsed { + appState.collapsedPageIndexes.remove(pageIndex) + } else { + appState.collapsedPageIndexes.insert(pageIndex) + } + } label: { + pageHeader } - } label: { - HStack { - Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") - .font(.caption2.weight(.semibold)) - .frame(width: 12) - .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) - Text("Page \(pageIndex + 1)") - .font(.caption.weight(.semibold)) - .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) - Spacer() - Text("\(items.count)") - .font(.caption.monospacedDigit()) - .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) - } - .padding(.horizontal, 10) - .padding(.top, 7) - .padding(.bottom, 5) + .buttonStyle(.plain) + .help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments") } - .buttonStyle(.plain) - .help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments") } if !isCollapsed { @@ -307,6 +396,25 @@ private struct PageCommentGroup: View { } } } + + private var pageHeader: some View { + HStack { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.caption2.weight(.semibold)) + .frame(width: 12) + .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) + Text("Page \(pageIndex + 1)") + .font(.caption.weight(.semibold)) + .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) + Spacer() + Text("\(visibleItemCount)") + .font(.caption.monospacedDigit()) + .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) + } + .padding(.horizontal, 10) + .padding(.top, 7) + .padding(.bottom, 5) + } } private struct CommentRow: View { diff --git a/Sources/IHatePDFsCore/AnnotationColorPreference.swift b/Sources/IHatePDFsCore/AnnotationColorPreference.swift new file mode 100644 index 0000000..e2413c5 --- /dev/null +++ b/Sources/IHatePDFsCore/AnnotationColorPreference.swift @@ -0,0 +1,84 @@ +import AppKit +import Foundation + +public enum AnnotationColorPreference { + public static func color( + from storageValue: String?, + fallback: NSColor, + minimumAlpha: CGFloat = 0 + ) -> NSColor { + guard let storageValue, + let color = color(from: storageValue) + else { + return normalized(fallback, fallback: fallback, minimumAlpha: minimumAlpha) + } + + return normalized(color, fallback: fallback, minimumAlpha: minimumAlpha) + } + + public static func storageString(for color: NSColor, fallback: String = "#FFD11F85") -> String { + guard let rgb = color.usingColorSpace(.deviceRGB) else { + return fallback + } + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return String( + format: "#%02X%02X%02X%02X", + byte(red), + byte(green), + byte(blue), + byte(alpha) + ) + } + + private static func color(from storageValue: String) -> NSColor? { + var raw = storageValue.trimmingCharacters(in: .whitespacesAndNewlines) + if raw.hasPrefix("#") { + raw.removeFirst() + } + + guard raw.count == 8, + let value = UInt32(raw, radix: 16) + else { + return nil + } + + let red = CGFloat((value >> 24) & 0xFF) / 255 + let green = CGFloat((value >> 16) & 0xFF) / 255 + let blue = CGFloat((value >> 8) & 0xFF) / 255 + let alpha = CGFloat(value & 0xFF) / 255 + return NSColor(deviceRed: red, green: green, blue: blue, alpha: alpha) + } + + private static func normalized( + _ color: NSColor, + fallback: NSColor, + minimumAlpha: CGFloat + ) -> NSColor { + guard let rgb = color.usingColorSpace(.deviceRGB) else { + return fallback + } + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return NSColor( + deviceRed: red, + green: green, + blue: blue, + alpha: max(alpha, minimumAlpha) + ) + } + + private static func byte(_ value: CGFloat) -> Int { + max(0, min(255, Int((value * 255).rounded()))) + } +} diff --git a/Sources/IHatePDFsCore/AnnotationFactory.swift b/Sources/IHatePDFsCore/AnnotationFactory.swift index ce2d846..9c41636 100644 --- a/Sources/IHatePDFsCore/AnnotationFactory.swift +++ b/Sources/IHatePDFsCore/AnnotationFactory.swift @@ -4,16 +4,16 @@ import PDFKit public enum AcademicAnnotationPalette { public static let comment = NSColor( - calibratedRed: 0.88, - green: 0.72, - blue: 0.46, - alpha: 0.10 + calibratedRed: 0.98, + green: 0.64, + blue: 0.16, + alpha: 0.30 ) public static let highlight = NSColor( - calibratedRed: 0.88, - green: 0.72, - blue: 0.46, - alpha: 0.24 + calibratedRed: 1.0, + green: 0.78, + blue: 0.0, + alpha: 0.52 ) public static let underline = NSColor( calibratedRed: 0.48, @@ -58,10 +58,13 @@ public enum MarkupAnnotationStyle { } } - var color: NSColor { + func color( + highlightColor: NSColor = AcademicAnnotationPalette.highlight, + commentColor: NSColor = AcademicAnnotationPalette.comment + ) -> NSColor { switch self { - case .comment: return AcademicAnnotationPalette.comment - case .highlight: return AcademicAnnotationPalette.highlight + case .comment: return commentColor + case .highlight: return highlightColor case .underline: return AcademicAnnotationPalette.underline } } @@ -95,6 +98,8 @@ public enum AnnotationFactory { style: MarkupAnnotationStyle, comment: String, author: String, + highlightColor: NSColor = AcademicAnnotationPalette.highlight, + commentColor: NSColor = AcademicAnnotationPalette.comment, date: Date = Date() ) -> [AnnotationInsertion] { let lineSelections = selection.selectionsByLine() @@ -120,7 +125,7 @@ public enum AnnotationFactory { } let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil) annotation.markupType = style.markupType - annotation.color = style.color + annotation.color = style.color(highlightColor: highlightColor, commentColor: commentColor) annotation.quadrilateralPoints = group.rects.flatMap { rect in quadPoints(for: rect, relativeTo: unionRect) } @@ -266,7 +271,9 @@ public enum AnnotationFactory { date: Date ) { AnnotationKeys.setCommentText(comment, for: annotation) - annotation.contents = comment + annotation.contents = comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : comment annotation.userName = author annotation.modificationDate = date annotation.shouldDisplay = true @@ -357,6 +364,51 @@ public enum AnnotationFactory { @discardableResult public static func restoreCommentTextForExport(_ annotation: PDFAnnotation) -> Bool { let contents = AnnotationKeys.commentText(for: annotation) + return restoreCommentText(contents, forExportIn: annotation) + } + + @discardableResult + public static func prepareForPreviewCompatibleExport( + _ annotation: PDFAnnotation, + on page: PDFPage + ) -> Bool { + let contents = AnnotationKeys.commentText(for: annotation) + var didChange = restoreCommentText(contents, forExportIn: annotation) + + guard !AnnotationKeys.annotation(annotation, hasSubtype: .freeText) else { + return didChange + } + + if let popup = annotation.popup { + if popup.page != nil { + page.removeAnnotation(popup) + } + annotation.popup = nil + didChange = true + } + + let linkedPopups = page.annotations.filter { candidate in + guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false } + return parentAnnotation(for: candidate) === annotation + } + + for popup in linkedPopups { + page.removeAnnotation(popup) + didChange = true + } + + if restoreCommentText(contents, forExportIn: annotation) { + didChange = true + } + + return didChange + } + + @discardableResult + private static func restoreCommentText( + _ contents: String, + forExportIn annotation: PDFAnnotation + ) -> Bool { let exportedContents = contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : contents diff --git a/Sources/IHatePDFsCore/AnnotationHitTesting.swift b/Sources/IHatePDFsCore/AnnotationHitTesting.swift new file mode 100644 index 0000000..c71f746 --- /dev/null +++ b/Sources/IHatePDFsCore/AnnotationHitTesting.swift @@ -0,0 +1,46 @@ +import Foundation +import PDFKit + +public enum AnnotationHitTesting { + public static func containsTextMarkupPoint( + _ point: CGPoint, + in annotation: PDFAnnotation, + tolerance: CGFloat = 3 + ) -> Bool { + guard AnnotationKeys.annotation(annotation, hasSubtype: .highlight) + || AnnotationKeys.annotation(annotation, hasSubtype: .underline) + else { + return annotation.bounds.insetBy(dx: -tolerance, dy: -tolerance).contains(point) + } + + let quadPoints = annotation.quadrilateralPoints ?? [] + guard !quadPoints.isEmpty else { + return annotation.bounds.insetBy(dx: -tolerance, dy: -tolerance).contains(point) + } + + var index = 0 + while index + 3 < quadPoints.count { + let points = quadPoints[index..<(index + 4)].map { value in + let relativePoint = value.pointValue + return CGPoint( + x: annotation.bounds.minX + relativePoint.x, + y: annotation.bounds.minY + relativePoint.y + ) + } + if boundingRect(for: points).insetBy(dx: -tolerance, dy: -tolerance).contains(point) { + return true + } + index += 4 + } + + return false + } + + private static func boundingRect(for points: [CGPoint]) -> CGRect { + guard let first = points.first else { return .null } + + return points.dropFirst().reduce(CGRect(origin: first, size: .zero)) { rect, point in + rect.union(CGRect(origin: point, size: .zero)) + } + } +} diff --git a/Sources/IHatePDFsCore/AnnotationModels.swift b/Sources/IHatePDFsCore/AnnotationModels.swift index 9f41f73..441b5fd 100644 --- a/Sources/IHatePDFsCore/AnnotationModels.swift +++ b/Sources/IHatePDFsCore/AnnotationModels.swift @@ -157,11 +157,16 @@ public enum AnnotationKeys { public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText") public static func commentText(for annotation: PDFAnnotation) -> String { - if let value = annotation.value(forAnnotationKey: appCommentText) as? String { + if let value = annotation.value(forAnnotationKey: appCommentText) as? String, + !value.isEmpty { return value } - return annotation.contents ?? "" + if let contents = annotation.contents, !contents.isEmpty { + return contents + } + + return annotation.popup?.contents ?? "" } public static func setCommentText(_ text: String, for annotation: PDFAnnotation) { @@ -287,54 +292,42 @@ public enum AnnotationKeys { public enum AnnotationReader { public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] { var result: [AnnotationSnapshot] = [] + var namedAnnotationIDs: [String: String]? for pageIndex in 0.. [AnnotationSnapshot] { + var result: [AnnotationSnapshot] = [] + var seenPageIndexes = Set() + var namedAnnotationIDs: [String: String]? + + for page in pages { + let pageIndex = document.index(for: page) + guard pageIndex != NSNotFound, seenPageIndexes.insert(pageIndex).inserted else { continue } + result.append(contentsOf: snapshots( + in: document, + page: page, + pageIndex: pageIndex, + namedAnnotationIDs: &namedAnnotationIDs + )) + } + + return sorted(result) + } + + public static func sorted(_ snapshots: [AnnotationSnapshot]) -> [AnnotationSnapshot] { + snapshots.sorted { left, right in if left.pageIndex != right.pageIndex { return left.pageIndex < right.pageIndex } @@ -344,4 +337,112 @@ public enum AnnotationReader { return left.bounds.minX < right.bounds.minX } } + + private static func snapshots( + in document: PDFDocument, + page: PDFPage, + pageIndex: Int, + namedAnnotationIDs: inout [String: String]? + ) -> [AnnotationSnapshot] { + var result: [AnnotationSnapshot] = [] + + for (annotationIndex, annotation) in page.annotations.enumerated() { + guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { continue } + + let kind = AcademicAnnotationKind(annotation: annotation) + let contents = AnnotationKeys.commentText(for: annotation) + guard kind != .other || !contents.isEmpty else { continue } + + let id = AnnotationKeys.stableID( + for: annotation, + pageIndex: pageIndex, + annotationIndex: annotationIndex + ) + let pageLabel = page.label ?? "\(pageIndex + 1)" + let author = annotation.userName + ?? annotation.value(forAnnotationKey: .textLabel) as? String + ?? "Unknown" + let createdAt = AnnotationKeys.dateValue(for: AnnotationKeys.creationDate, in: annotation) + ?? annotation.modificationDate + let status = annotation.value(forAnnotationKey: AnnotationKeys.state) as? String + ?? "Unmarked" + let parentID = parentID( + for: annotation, + document: document, + namedAnnotationIDs: &namedAnnotationIDs + ) + + result.append( + AnnotationSnapshot( + id: id, + pageIndex: pageIndex, + pageLabel: pageLabel, + annotationIndex: annotationIndex, + kind: kind, + author: author, + createdAt: createdAt, + modifiedAt: annotation.modificationDate, + status: status, + contents: contents, + bounds: annotation.bounds, + annotation: annotation, + page: page, + parentID: parentID + ) + ) + } + + return result + } + + private static func parentID( + for annotation: PDFAnnotation, + document: PDFDocument, + namedAnnotationIDs: inout [String: String]? + ) -> String? { + if let parentID = annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? String, + !parentID.isEmpty { + if namedAnnotationIDs == nil { + namedAnnotationIDs = makeNamedAnnotationIDs(in: document) + } + return namedAnnotationIDs?[parentID] + } + + guard let parent = annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? PDFAnnotation else { + return nil + } + + guard let page = parent.page, + document.index(for: page) != NSNotFound + else { + return parent.value(forAnnotationKey: .name) as? String + } + + let pageIndex = document.index(for: page) + let annotationIndex = page.annotations.firstIndex(where: { $0 === parent }) ?? 0 + return AnnotationKeys.stableID(for: parent, pageIndex: pageIndex, annotationIndex: annotationIndex) + } + + private static func makeNamedAnnotationIDs(in document: PDFDocument) -> [String: String] { + var result: [String: String] = [:] + + for pageIndex in 0.. Bool { + guard url.isFileURL else { return false } + + let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey, .isDirectoryKey]) + if resourceValues?.isDirectory == true { + return false + } + + if let contentType = resourceValues?.contentType { + return contentType.conforms(to: .pdf) + } + + return url.pathExtension.localizedCaseInsensitiveCompare("pdf") == .orderedSame + } +} diff --git a/Sources/IHatePDFsCore/ReturnKeyCommitPolicy.swift b/Sources/IHatePDFsCore/ReturnKeyCommitPolicy.swift new file mode 100644 index 0000000..6acf6f3 --- /dev/null +++ b/Sources/IHatePDFsCore/ReturnKeyCommitPolicy.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum ReturnKeyCommitPolicy { + public static func shouldCommit( + keyCode: UInt16, + shift: Bool, + option: Bool, + command: Bool, + control: Bool, + isEditableMultilineText: Bool + ) -> Bool { + guard isEditableMultilineText else { return false } + guard keyCode == 36 || keyCode == 76 else { return false } + return !shift && !option && !command && !control + } +} diff --git a/Tests/IHatePDFsCoreTests/AnnotationColorPreferenceTests.swift b/Tests/IHatePDFsCoreTests/AnnotationColorPreferenceTests.swift new file mode 100644 index 0000000..b2adcda --- /dev/null +++ b/Tests/IHatePDFsCoreTests/AnnotationColorPreferenceTests.swift @@ -0,0 +1,74 @@ +import XCTest +import AppKit +@testable import IHatePDFsCore + +final class AnnotationColorPreferenceTests: XCTestCase { + func testColorPreferenceRoundTripsRGBAStorage() throws { + let color = NSColor(deviceRed: 0.25, green: 0.5, blue: 0.75, alpha: 0.4) + let storage = AnnotationColorPreference.storageString(for: color) + XCTAssertEqual(storage, "#4080BF66") + + let decoded = AnnotationColorPreference.color( + from: storage, + fallback: AcademicAnnotationPalette.highlight + ) + let components = try rgbaComponents(decoded) + + XCTAssertEqual(components.red, 0x40 / 255, accuracy: 0.001) + XCTAssertEqual(components.green, 0x80 / 255, accuracy: 0.001) + XCTAssertEqual(components.blue, 0xBF / 255, accuracy: 0.001) + XCTAssertEqual(components.alpha, 0x66 / 255, accuracy: 0.001) + } + + func testColorPreferenceUsesFallbackForInvalidStorage() throws { + let decoded = AnnotationColorPreference.color( + from: "not-a-color", + fallback: AcademicAnnotationPalette.comment + ) + + try XCTAssertColor(decoded, equals: AcademicAnnotationPalette.comment) + } + + func testColorPreferenceAppliesMinimumAlphaWithoutChangingRGB() throws { + let decoded = AnnotationColorPreference.color( + from: "#33669905", + fallback: AcademicAnnotationPalette.highlight, + minimumAlpha: 0.3 + ) + let components = try rgbaComponents(decoded) + + XCTAssertEqual(components.red, 0x33 / 255, accuracy: 0.001) + XCTAssertEqual(components.green, 0x66 / 255, accuracy: 0.001) + XCTAssertEqual(components.blue, 0x99 / 255, accuracy: 0.001) + XCTAssertEqual(components.alpha, 0.3, accuracy: 0.001) + } + + private func XCTAssertColor( + _ actual: NSColor, + equals expected: NSColor, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let actualComponents = try rgbaComponents(actual, file: file, line: line) + let expectedComponents = try rgbaComponents(expected, file: file, line: line) + + XCTAssertEqual(actualComponents.red, expectedComponents.red, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualComponents.green, expectedComponents.green, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualComponents.blue, expectedComponents.blue, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualComponents.alpha, expectedComponents.alpha, accuracy: 0.001, file: file, line: line) + } + + private func rgbaComponents( + _ color: NSColor, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + let rgb = try XCTUnwrap(color.usingColorSpace(.deviceRGB), file: file, line: line) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return (red, green, blue, alpha) + } +} diff --git a/Tests/IHatePDFsCoreTests/AnnotationFactoryTests.swift b/Tests/IHatePDFsCoreTests/AnnotationFactoryTests.swift index f1b66d7..ae6adbb 100644 --- a/Tests/IHatePDFsCoreTests/AnnotationFactoryTests.swift +++ b/Tests/IHatePDFsCoreTests/AnnotationFactoryTests.swift @@ -42,6 +42,97 @@ final class AnnotationFactoryTests: XCTestCase { ) } + func testHighlightUsesHigherContrastDefaultColor() throws { + let document = try makeSelectableTextDocument() + let page = try XCTUnwrap(document.page(at: 0)) + let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29))) + let insertion = try XCTUnwrap( + AnnotationFactory.markupInsertions( + from: selection, + style: .highlight, + comment: "", + author: "Professor" + ).first + ) + + let annotationColor = try rgbaComponents(insertion.annotation.color) + let defaultColor = try rgbaComponents(AcademicAnnotationPalette.highlight) + XCTAssertEqual(annotationColor.red, defaultColor.red, accuracy: 0.001) + XCTAssertEqual(annotationColor.green, defaultColor.green, accuracy: 0.001) + XCTAssertEqual(annotationColor.blue, defaultColor.blue, accuracy: 0.001) + XCTAssertEqual(annotationColor.alpha, defaultColor.alpha, accuracy: 0.001) + XCTAssertGreaterThanOrEqual(annotationColor.alpha, 0.5) + } + + func testHighlightCreatedWithoutCommentHasNoPopupOrCommentText() throws { + let document = try makeSelectableTextDocument() + let page = try XCTUnwrap(document.page(at: 0)) + let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29))) + let insertion = try XCTUnwrap( + AnnotationFactory.markupInsertions( + from: selection, + style: .highlight, + comment: "", + author: "Professor" + ).first + ) + + XCTAssertNil(insertion.popup) + XCTAssertEqual(AnnotationKeys.commentText(for: insertion.annotation), "") + XCTAssertNil(insertion.annotation.contents) + XCTAssertEqual(AcademicAnnotationKind(annotation: insertion.annotation), .highlight) + } + + func testHighlightUsesConfiguredColor() throws { + let document = try makeSelectableTextDocument() + let page = try XCTUnwrap(document.page(at: 0)) + let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29))) + let configuredColor = NSColor( + calibratedRed: 0.18, + green: 0.58, + blue: 0.95, + alpha: 0.52 + ) + let insertion = try XCTUnwrap( + AnnotationFactory.markupInsertions( + from: selection, + style: .highlight, + comment: "", + author: "Professor", + highlightColor: configuredColor + ).first + ) + + try XCTAssertColor(insertion.annotation.color, equals: configuredColor) + } + + func testSelectionBoundCommentUsesConfiguredColor() throws { + let document = try makeSelectableTextDocument() + let page = try XCTUnwrap(document.page(at: 0)) + let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10))) + let configuredColor = NSColor( + calibratedRed: 0.88, + green: 0.18, + blue: 0.26, + alpha: 0.34 + ) + let insertion = try XCTUnwrap( + AnnotationFactory.markupInsertions( + from: selection, + style: .comment, + comment: "", + author: "Professor", + commentColor: configuredColor + ).first + ) + + try XCTAssertColor(insertion.annotation.color, equals: configuredColor) + XCTAssertEqual( + insertion.annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String, + AnnotationKeys.appKindComment + ) + } + func testSelectionBoundCommentRoundTripsAsCommentKind() throws { let document = try makeSelectableTextDocument() let page = try XCTUnwrap(document.page(at: 0)) @@ -205,6 +296,108 @@ final class AnnotationFactoryTests: XCTestCase { }) } + func testPreviewCompatibleExportKeepsMarkupCommentWithoutPopupAnnotation() throws { + let document = try makeSelectableTextDocument() + let page = try XCTUnwrap(document.page(at: 0)) + let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29))) + let insertion = try XCTUnwrap( + AnnotationFactory.markupInsertions( + from: selection, + style: .highlight, + comment: "Preview should show this comment.", + author: "Professor" + ).first + ) + + page.addAnnotation(insertion.annotation) + if let popup = insertion.popup { + page.addAnnotation(popup) + } + + XCTAssertTrue(AnnotationFactory.prepareForPreviewCompatibleExport(insertion.annotation, on: page)) + XCTAssertNil(insertion.annotation.popup) + XCTAssertEqual(insertion.annotation.contents, "Preview should show this comment.") + XCTAssertFalse(page.annotations.contains { + AnnotationKeys.annotation($0, hasSubtype: .popup) + }) + + let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap() + let highlights = reopenedPage.annotations.filter { + AnnotationKeys.annotation($0, hasSubtype: .highlight) + } + + XCTAssertEqual(highlights.count, 1) + XCTAssertEqual(highlights.first?.contents, "Preview should show this comment.") + XCTAssertFalse(reopenedPage.annotations.contains { + AnnotationKeys.annotation($0, hasSubtype: .popup) + }) + } + + func testPreviewCompatibleExportRecoversPopupOnlyCommentText() throws { + let document = try makeSelectableTextDocument() + let page = try XCTUnwrap(document.page(at: 0)) + let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29))) + let bounds = selection.bounds(for: page) + let annotation = PDFAnnotation(bounds: bounds, forType: .highlight, withProperties: nil) + annotation.markupType = .highlight + annotation.color = AcademicAnnotationPalette.highlight + annotation.userName = "Professor" + annotation.quadrilateralPoints = [ + NSValue(point: CGPoint(x: 0, y: bounds.height)), + NSValue(point: CGPoint(x: bounds.width, y: bounds.height)), + NSValue(point: .zero), + NSValue(point: CGPoint(x: bounds.width, y: 0)) + ] + let popup = PDFAnnotation( + bounds: CGRect(x: 360, y: 620, width: 220, height: 90), + forType: .popup, + withProperties: nil + ) + popup.contents = "Popup-only comment from another reader." + annotation.popup = popup + + page.addAnnotation(annotation) + page.addAnnotation(popup) + + XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Popup-only comment from another reader.") + XCTAssertTrue(AnnotationFactory.prepareForPreviewCompatibleExport(annotation, on: page)) + XCTAssertNil(annotation.popup) + XCTAssertEqual(annotation.contents, "Popup-only comment from another reader.") + XCTAssertFalse(page.annotations.contains { + AnnotationKeys.annotation($0, hasSubtype: .popup) + }) + } + + func testEmptyAppCommentTextFallsBackToStandardContents() throws { + let annotation = PDFAnnotation( + bounds: CGRect(x: 72, y: 620, width: 260, height: 24), + forType: .highlight, + withProperties: nil + ) + AnnotationKeys.setCommentText("", for: annotation) + annotation.contents = "Comment added by another PDF reader." + + XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Comment added by another PDF reader.") + } + + func testEmptyAppCommentTextFallsBackToPopupContents() throws { + let annotation = PDFAnnotation( + bounds: CGRect(x: 72, y: 620, width: 260, height: 24), + forType: .highlight, + withProperties: nil + ) + let popup = PDFAnnotation( + bounds: CGRect(x: 360, y: 620, width: 220, height: 90), + forType: .popup, + withProperties: nil + ) + AnnotationKeys.setCommentText("", for: annotation) + popup.contents = "Popup comment added by another PDF reader." + annotation.popup = popup + + XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Popup comment added by another PDF reader.") + } + func testAddingAnnotationPreservesPriorAnnotation() throws { let document = try makeSelectableTextDocument() let page = try XCTUnwrap(document.page(at: 0)) @@ -379,6 +572,66 @@ final class AnnotationFactoryTests: XCTestCase { XCTAssertEqual(replySnapshot.parentID, parentSnapshot.id) } + func testUnresolvedStringReplyParentIDStaysVisibleAsTopLevelReply() throws { + let document = PDFDocument() + let page = PDFPage() + document.insert(page, at: 0) + + let orphanedReply = PDFAnnotation( + bounds: CGRect(x: 100, y: 100, width: 24, height: 24), + forType: .text, + withProperties: nil + ) + AnnotationFactory.standardize( + orphanedReply, + comment: "Reply from an external reader.", + author: "Reader", + date: Date() + ) + _ = orphanedReply.setValue("missing-parent", forAnnotationKey: AnnotationKeys.inReplyTo) + _ = orphanedReply.setValue("R", forAnnotationKey: AnnotationKeys.replyType) + page.addAnnotation(orphanedReply) + + let snapshot = try XCTUnwrap(AnnotationReader.snapshots(in: document).first) + XCTAssertEqual(snapshot.kind, .reply) + XCTAssertNil(snapshot.parentID) + XCTAssertFalse(snapshot.isReply) + XCTAssertEqual(snapshot.contents, "Reply from an external reader.") + } + + func testPageScopedSnapshotsOnlyReadRequestedPages() throws { + let document = PDFDocument() + let firstPage = PDFPage() + let secondPage = PDFPage() + let thirdPage = PDFPage() + document.insert(firstPage, at: 0) + document.insert(secondPage, at: 1) + document.insert(thirdPage, at: 2) + + let firstAnnotation = AnnotationFactory.noteInsertion( + on: firstPage, + near: CGPoint(x: 100, y: 100), + comment: "First page note", + author: "Professor" + ).annotation + firstPage.addAnnotation(firstAnnotation) + + let thirdAnnotation = AnnotationFactory.noteInsertion( + on: thirdPage, + near: CGPoint(x: 200, y: 200), + comment: "Third page note", + author: "Professor" + ).annotation + thirdPage.addAnnotation(thirdAnnotation) + + let scopedSnapshots = AnnotationReader.snapshots(in: document, pages: [thirdPage, thirdPage]) + + XCTAssertEqual(scopedSnapshots.count, 1) + XCTAssertEqual(scopedSnapshots.first?.contents, "Third page note") + XCTAssertEqual(scopedSnapshots.first?.pageIndex, 2) + XCTAssertFalse(scopedSnapshots.contains { $0.annotation === firstAnnotation }) + } + func testFreeTextCreatesStandardFreeTextAnnotation() throws { let page = PDFPage() let insertion = AnnotationFactory.freeTextInsertion( @@ -439,6 +692,35 @@ final class AnnotationFactoryTests: XCTestCase { try? FileManager.default.removeItem(at: outputURL) return reopened } + + private func XCTAssertColor( + _ actual: NSColor, + equals expected: NSColor, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let actualComponents = try rgbaComponents(actual, file: file, line: line) + let expectedComponents = try rgbaComponents(expected, file: file, line: line) + + XCTAssertEqual(actualComponents.red, expectedComponents.red, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualComponents.green, expectedComponents.green, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualComponents.blue, expectedComponents.blue, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualComponents.alpha, expectedComponents.alpha, accuracy: 0.001, file: file, line: line) + } + + private func rgbaComponents( + _ color: NSColor, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + let rgb = try XCTUnwrap(color.usingColorSpace(.deviceRGB), file: file, line: line) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return (red, green, blue, alpha) + } } private extension Optional { diff --git a/Tests/IHatePDFsCoreTests/AnnotationHitTestingTests.swift b/Tests/IHatePDFsCoreTests/AnnotationHitTestingTests.swift new file mode 100644 index 0000000..51feefa --- /dev/null +++ b/Tests/IHatePDFsCoreTests/AnnotationHitTestingTests.swift @@ -0,0 +1,38 @@ +import XCTest +import PDFKit +@testable import IHatePDFsCore + +final class AnnotationHitTestingTests: XCTestCase { + func testTextMarkupHitTestingUsesQuadPointsInsteadOfUnionBounds() { + let annotation = PDFAnnotation( + bounds: CGRect(x: 10, y: 20, width: 100, height: 60), + forType: .highlight, + withProperties: nil + ) + annotation.quadrilateralPoints = [ + NSValue(point: CGPoint(x: 0, y: 55)), + NSValue(point: CGPoint(x: 100, y: 55)), + NSValue(point: CGPoint(x: 0, y: 45)), + NSValue(point: CGPoint(x: 100, y: 45)), + NSValue(point: CGPoint(x: 0, y: 15)), + NSValue(point: CGPoint(x: 100, y: 15)), + NSValue(point: CGPoint(x: 0, y: 5)), + NSValue(point: CGPoint(x: 100, y: 5)) + ] + + XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 70), in: annotation)) + XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 30), in: annotation)) + XCTAssertFalse(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 50), in: annotation)) + } + + func testTextMarkupHitTestingFallsBackToBoundsWithoutQuadPoints() { + let annotation = PDFAnnotation( + bounds: CGRect(x: 10, y: 20, width: 100, height: 20), + forType: .underline, + withProperties: nil + ) + + XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 30), in: annotation)) + XCTAssertFalse(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 60), in: annotation)) + } +} diff --git a/Tests/IHatePDFsCoreTests/PDFFileSelectionTests.swift b/Tests/IHatePDFsCoreTests/PDFFileSelectionTests.swift new file mode 100644 index 0000000..ad19cdc --- /dev/null +++ b/Tests/IHatePDFsCoreTests/PDFFileSelectionTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import IHatePDFsCore + +final class PDFFileSelectionTests: XCTestCase { + func testPDFFileURLAcceptsPDFExtensionsCaseInsensitively() { + XCTAssertTrue(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/article.pdf"))) + XCTAssertTrue(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/article.PDF"))) + } + + func testPDFFileURLRejectsNonPDFAndRemoteURLs() { + XCTAssertFalse(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/notes.txt"))) + XCTAssertFalse(PDFFileSelection.isPDFFileURL(URL(string: "https://example.com/article.pdf")!)) + } + + func testPDFFileURLRejectsDirectoriesNamedLikePDFs() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("pdf") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + XCTAssertFalse(PDFFileSelection.isPDFFileURL(directory)) + } +} diff --git a/Tests/IHatePDFsCoreTests/ReturnKeyCommitPolicyTests.swift b/Tests/IHatePDFsCoreTests/ReturnKeyCommitPolicyTests.swift new file mode 100644 index 0000000..2558c1f --- /dev/null +++ b/Tests/IHatePDFsCoreTests/ReturnKeyCommitPolicyTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import IHatePDFsCore + +final class ReturnKeyCommitPolicyTests: XCTestCase { + func testPlainReturnCommitsInEditableMultilineText() { + XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit( + keyCode: 36, + shift: false, + option: false, + command: false, + control: false, + isEditableMultilineText: true + )) + } + + func testKeypadEnterCommitsInEditableMultilineText() { + XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit( + keyCode: 76, + shift: false, + option: false, + command: false, + control: false, + isEditableMultilineText: true + )) + } + + func testShiftReturnDoesNotCommitSoTextViewCanInsertNewline() { + XCTAssertFalse(ReturnKeyCommitPolicy.shouldCommit( + keyCode: 36, + shift: true, + option: false, + command: false, + control: false, + isEditableMultilineText: true + )) + } + + func testReturnDoesNotCommitOutsideEditableMultilineText() { + XCTAssertFalse(ReturnKeyCommitPolicy.shouldCommit( + keyCode: 36, + shift: false, + option: false, + command: false, + control: false, + isEditableMultilineText: false + )) + } +} diff --git a/docs/APP_STORE.md b/docs/APP_STORE.md new file mode 100644 index 0000000..704ca4a --- /dev/null +++ b/docs/APP_STORE.md @@ -0,0 +1,44 @@ +# Mac App Store Release + +Bundle ID: `net.akkolli.ihatepdfs` + +Current App Store build values: + +- `CFBundleShortVersionString`: `0.3.0` +- `CFBundleVersion`: `4` +- Privacy policy URL: `https://www.akkolli.net/ihatepdfs/privacy` + +## Required Apple Developer Items + +- An explicit macOS App ID for `net.akkolli.ihatepdfs`. +- An App Store provisioning profile for that App ID. +- An application signing certificate installed in Keychain, usually named `Apple Distribution: ...` or `3rd Party Mac Developer Application: ...`. +- An installer signing certificate installed in Keychain, usually named `3rd Party Mac Developer Installer: ...`. Apple may label this certificate type as Mac Installer Distribution in the developer portal or Xcode. + +The app only needs these sandbox entitlements right now: + +- `com.apple.security.app-sandbox` +- `com.apple.security.files.user-selected.read-write` + +Do not add network, Apple Events, Downloads-folder, or bookmark entitlements unless the app gains a feature that requires them. + +## Build The Upload Package + +Download the App Store provisioning profile from Apple Developer, then run: + +```sh +APP_SIGNING_IDENTITY="3rd Party Mac Developer Application: Your Name (TEAMID)" \ +INSTALLER_SIGNING_IDENTITY="3rd Party Mac Developer Installer: Your Name (TEAMID)" \ +PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \ +scripts/make-app-store-pkg.sh +``` + +The package is written to `dist/IHatePDFs-v0.3-macos-appstore.pkg`. + +The script derives the App Store application identifier and team identifier from the provisioning profile before signing. It also clears download quarantine metadata from the bundle before packaging, because App Store Connect rejects packages that contain quarantine extended attributes. + +## Upload + +Upload the `.pkg` with Transporter. You can also set `VALIDATE_WITH_ALTOOL=1` when running `scripts/make-app-store-pkg.sh` if you want the script to perform an `altool` validation after packaging. After App Store Connect processes the build, select it in the app version, finish metadata, answer App Privacy, fill review notes, and submit for review. + +Keep `CFBundleShortVersionString` as `0.3.0` and `CFBundleVersion` as `4` for this upload. Increment `BUILD_NUMBER` in `scripts/release-version.sh` before uploading another build for the same version. diff --git a/docs/ENGINEERING.md b/docs/ENGINEERING.md new file mode 100644 index 0000000..5f994f8 --- /dev/null +++ b/docs/ENGINEERING.md @@ -0,0 +1,70 @@ +# Engineering Principles + +I Hate PDFs is intentionally a small native macOS app. Future work should preserve that constraint unless there is a documented, user-visible reason to do otherwise. + +## Native First + +- Build features with Swift, SwiftUI, AppKit, PDFKit, and other system frameworks that ship with macOS. +- Do not replace the app with Electron, Chromium, a web runtime, a bundled JavaScript app shell, or a cross-platform UI toolkit. +- Do not bundle a PDF renderer, OCR engine, database, scripting runtime, or large framework when a macOS system API can satisfy the requirement. +- Prefer native macOS controls and document behaviors over custom reimplementations when they meet the product need. + +## Small By Default + +Every change should aim for the smallest final app that still delivers the required fluidity, reliability, and functionality. + +- Keep third-party dependencies at or near zero. Any new package must justify its shipped size, runtime cost, maintenance cost, and why system APIs are insufficient. +- Keep assets minimal. Avoid large raster images, fonts, sample PDFs, videos, model files, or generated resources in the app bundle. +- Keep build outputs out of source and releases unless they are intentional release artifacts. +- Prefer dynamic links to Apple system frameworks over vendored libraries. +- Avoid storing duplicate PDF data, rendered page caches, or annotation indexes unless profiling shows they are required for fluid interaction. +- Favor targeted updates over whole-document rescans for common interactions such as editing, replying, filtering, hovering, and sidebar refreshes. + +## Size Budget + +The release DMG should stay as small as practical. Treat size growth as a product regression, not just a packaging detail. + +Before merging release-impacting work, compare: + +```sh +scripts/build-app.sh +scripts/make-dmg.sh +du -sh "dist/I Hate PDFs.app" \ + "dist/I Hate PDFs.app/Contents/MacOS/IHatePDFs" \ + "dist/I Hate PDFs.app/Contents/Resources/AppIcon.icns" \ + dist/IHatePDFs-v*-macos.dmg +``` + +If a change materially increases the app bundle or DMG size, document why in the PR or commit notes. A useful rule of thumb: any dependency addition, bundled asset addition, or release-size increase above roughly 10% needs explicit justification. + +## Performance Budget + +Small size should not come at the expense of reader fluidity. + +- Opening, scrolling, zooming, searching, annotating, saving, and sidebar navigation should remain responsive on long PDFs. +- Optimize around measured user workflows instead of speculative micro-optimizations. +- Keep expensive work page-scoped or lazy when possible. +- Use `swift test` plus the PDF verification scripts after behavior changes: + +```sh +swift test +swift scripts/verify-sample-pdf.swift +swift scripts/verify-pdf-annotations.swift +``` + +## Release Packaging + +Release builds should use the existing lightweight packaging path: + +```sh +scripts/build-app.sh +scripts/make-dmg.sh +``` + +`scripts/build-app.sh` strips release binaries by default to reduce shipped size. Use `STRIP_RELEASE=0 scripts/build-app.sh` only when a symbol-rich release build is needed for debugging. + +Universal `arm64` + `x86_64` builds are the default for public releases. Single-architecture builds are acceptable for local testing: + +```sh +ARCHS="" scripts/build-app.sh +``` diff --git a/docs/QA.md b/docs/QA.md index 92e0907..f7c97fa 100644 --- a/docs/QA.md +++ b/docs/QA.md @@ -15,20 +15,50 @@ Use at least: ## App Workflow 1. Open the PDF in I Hate PDFs. -2. Select text and add a highlight. -3. Add a comment to the highlight. -4. Add an underline with a comment. -5. Select text, right-click, and add a comment from the context menu. -6. Add free text directly on the page. -7. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate. -8. Quit and reopen the same PDF at the same approximate window width and verify the app restores that PDF's sidebar state; then open a different PDF and verify it starts in focused single-pane reading unless that document has its own saved state. -9. Add at least one reply and verify the comments sidebar presents the thread like a clean review/chat stream, with a visible connector line from the parent comment to the reply. -10. Hover a comment row and verify the corresponding PDF text is highlighted; click both the parent comment text and the reply text in the sidebar and verify the PDF view navigates to and selects the corresponding annotation. -11. Verify highlights, comment markers, hidden page-level replies, and selected sidebar rows use muted native-feeling colors in light mode and do not visually overpower the document. -12. Switch the app to dark mode and verify the reading background, comments sidebar, editor popover, connector lines, selected rows, text fields, and annotation markers remain legible and restrained. -13. Save As an annotated copy. -14. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain. -15. Save over a disposable original and verify the overwrite warning appears. +2. Close the PDF, then drag a `.pdf` file onto the empty no-document window and verify it opens. +3. Open Settings from File > Settings... and with Command-, then verify highlight color, comment color, and opacity changes can be edited and reset. +4. Select text and add a highlight; verify no comment popover opens. +5. Select text and add a comment; verify the comment color matches the Settings value. +6. In the comment box, press Shift-Return and verify it inserts a new line, then press Return and verify the comment is saved. +7. Add an underline with a comment. +8. Select text, right-click, and add a comment from the context menu. +9. Add free text directly on the page. +10. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate. +11. Quit and reopen the same PDF at the same approximate window width and verify the app restores that PDF's sidebar state; then open a different PDF and verify it starts in focused single-pane reading unless that document has its own saved state. +12. Add at least one reply and verify the comments sidebar presents the thread like a clean review/chat stream, with a visible connector line from the parent comment to the reply. +13. Hover a comment row and verify the corresponding PDF text is highlighted; click both the parent comment text and the reply text in the sidebar and verify the PDF view navigates to and selects the corresponding annotation. +14. Click on commented text and underlined text and verify the comment popover opens; then click the line below or nearby whitespace and verify no popover opens. +15. Verify highlights, comment markers, hidden page-level replies, and selected sidebar rows use muted native-feeling colors in light mode and do not visually overpower the document. +16. Switch the app to dark mode and verify the reading background, comments sidebar, editor popover, connector lines, selected rows, text fields, and annotation markers remain legible and restrained. +17. Save As an annotated copy. +18. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain. +19. Save over a disposable original and verify the overwrite warning appears. +20. Add an annotation and verify the window shows the native macOS unsaved/edited document indicator until the PDF is saved. +21. Search for a word, close the search toolbar, and verify the match highlights disappear; repeat after opening a different PDF to confirm stale search highlights do not carry over. +22. Type an invalid page number and an out-of-range page number in the page field, and verify the app restores the current page number with a clear status message; also verify previous/next page controls disable at the first and last pages. +23. Apply comment filters or search text that hide every comment, verify the empty state offers Clear Filters, and verify page counts include visible replies. +24. Collapse a page group in the comments sidebar, search for a comment on that page, and verify the matching results are shown while the filter is active. +25. Start typing a sidebar reply, click Reply on a different comment, and verify the original draft remains until you send or cancel it. +26. Click one comment row, then click Reply on a different comment or reply, and verify the sidebar selection and PDF highlight move to the reply target. +27. Click one comment row, then click Edit or the review-status chip on a different row, and verify the sidebar selection and PDF highlight move to the edited or reviewed row. +28. Set a comments-sidebar filter and collapse a page group, then open another PDF and verify the comments sidebar starts unfiltered with page groups expanded. +29. In Settings, choose very low-opacity highlight and comment colors, add each annotation type, and verify saved annotations remain visibly readable. +30. Start typing a sidebar reply without sending it, then close or replace the PDF and verify the app asks before discarding the draft and the window shows the edited indicator while the draft exists. +31. Start typing a sidebar reply without sending it, choose Share, and verify the app warns that the draft will not be included unless it is sent first. +32. Start typing a sidebar reply, delete the comment thread it belongs to, and verify the app asks before discarding the reply draft. +33. Add replies to a comment, delete the parent comment from both the sidebar and the popover path in separate runs, and verify the whole thread is removed each time. +34. Hover a comment row until the matching PDF annotation highlights, then hide the comments sidebar or apply a filter that removes the row and verify the hover highlight clears. +35. Hover and click a sidebar reply, and verify the PDF scrolls to and highlights the visible parent annotation rather than jumping to a hidden reply marker. +36. Search for a word with matches, edit the search field without pressing Return, and verify old PDF match highlights clear and previous/next search buttons disable until the new query is submitted. +37. Start typing a sidebar reply without sending it, choose Save As, and verify the app warns that the draft will not be included unless it is sent first. +38. Create a new selected-text comment or free text, leave its popover empty, choose Save before closing the popover, and verify the temporary empty annotation is discarded instead of saved. +39. Start typing a sidebar reply with no other unsaved annotation changes and verify the status bar shows a reply draft instead of presenting the PDF as clean. +40. Search for a word with matches, step through results, and verify the status bar reports the current match position; then search for text that is not present and verify PDF match highlights clear. +41. Open comments search and verify the field is focused immediately; enter a search, hide the search controls, and verify the search icon still indicates an active hidden filter. +42. Select a comment row, apply a comments-sidebar filter or search that hides that row, and verify the PDF selection highlight clears instead of lingering on the page. +43. Create a new selected-text comment or free text, leave its popover empty, choose Share, and verify the temporary empty annotation is discarded before any Save and Share output is written. +44. Select a comment or annotation row, hide the only sidebar that shows that row, and verify the PDF selection highlight clears; repeat while the left Annotations sidebar is visible and verify the selection stays visible there. +45. Select a comment row, collapse its page group in the comments sidebar, and verify the PDF selection highlight clears; then search/filter comments and verify matching page groups expand while filtering. ## External Readers @@ -39,7 +69,9 @@ swift scripts/verify-sample-pdf.swift swift scripts/verify-pdf-annotations.swift ``` -These checks generate an annotated PDF, reopen it with PDFKit, and inspect the raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Popup`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries. +These checks generate an annotated PDF, reopen it with PDFKit, and inspect the raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries. + +For Preview interoperability, exported markup comments should keep the comment text on the parent annotation's standard `/Contents` key and should not depend on PDFKit-generated `/Popup` links for highlights or underlines. Open the saved annotated copy in: diff --git a/scripts/build-app.sh b/scripts/build-app.sh index b7a83c5..4d6396d 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -3,15 +3,22 @@ set -euo pipefail APP_NAME="I Hate PDFs" EXECUTABLE_NAME="IHatePDFs" -APP_VERSION="${APP_VERSION:-0.2.0}" -BUILD_NUMBER="${BUILD_NUMBER:-2}" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT_DIR/scripts/release-version.sh" CONFIGURATION="${CONFIGURATION:-release}" +BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}" +STRIP_RELEASE="${STRIP_RELEASE:-1}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" +ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-}" +PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}" +CODESIGN_TIMESTAMP="${CODESIGN_TIMESTAMP:-1}" +CODESIGN_OPTIONS="${CODESIGN_OPTIONS:-}" +PLISTBUDDY="/usr/libexec/PlistBuddy" if [[ -z "${ARCHS+x}" && "$CONFIGURATION" == "release" ]]; then ARCHS="arm64 x86_64" else ARCHS="${ARCHS:-}" fi -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST_DIR="$ROOT_DIR/dist" APP_DIR="$DIST_DIR/$APP_NAME.app" CONTENTS_DIR="$APP_DIR/Contents" @@ -19,6 +26,29 @@ MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" ICON_SOURCE="$ROOT_DIR/ihatepdf.png" ICON_NAME="AppIcon" +DERIVED_ENTITLEMENTS_PATH="" +PROFILE_PLIST_PATH="" + +cleanup() { + if [[ -n "$DERIVED_ENTITLEMENTS_PATH" ]]; then + rm -f "$DERIVED_ENTITLEMENTS_PATH" + fi + if [[ -n "$PROFILE_PLIST_PATH" ]]; then + rm -f "$PROFILE_PLIST_PATH" + fi +} +trap cleanup EXIT + +set_plist_string() { + local plist="$1" + local key="$2" + local value="$3" + + if "$PLISTBUDDY" -c "Set :$key $value" "$plist" >/dev/null 2>&1; then + return + fi + "$PLISTBUDDY" -c "Add :$key string $value" "$plist" +} cd "$ROOT_DIR" SWIFT_BUILD_ARGS=(-c "$CONFIGURATION") @@ -26,13 +56,26 @@ for ARCH in $ARCHS; do SWIFT_BUILD_ARGS+=(--arch "$ARCH") done -swift build "${SWIFT_BUILD_ARGS[@]}" BUILD_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)" +swift build "${SWIFT_BUILD_ARGS[@]}" rm -rf "$APP_DIR" mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME" +if [[ "$CONFIGURATION" == "release" && "$STRIP_RELEASE" != "0" ]]; then + strip -x "$MACOS_DIR/$EXECUTABLE_NAME" +fi + +if [[ -n "$PROVISIONING_PROFILE" ]]; then + if [[ ! -f "$PROVISIONING_PROFILE" ]]; then + echo "Missing provisioning profile: $PROVISIONING_PROFILE" >&2 + exit 1 + fi + cp "$PROVISIONING_PROFILE" "$CONTENTS_DIR/embedded.provisionprofile" + xattr -cr "$CONTENTS_DIR/embedded.provisionprofile" 2>/dev/null || true +fi + if [[ ! -f "$ICON_SOURCE" ]]; then echo "Missing app icon source: $ICON_SOURCE" >&2 exit 1 @@ -72,7 +115,7 @@ cat > "$CONTENTS_DIR/Info.plist" <CFBundleExecutable $EXECUTABLE_NAME CFBundleIdentifier - org.ihatepdfs.app + $BUNDLE_ID CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -104,6 +147,8 @@ cat > "$CONTENTS_DIR/Info.plist" <$BUILD_NUMBER LSMinimumSystemVersion 13.0 + LSApplicationCategoryType + public.app-category.productivity NSHighResolutionCapable NSSupportsAutomaticGraphicsSwitching @@ -116,4 +161,51 @@ cat > "$CONTENTS_DIR/Info.plist" < PLIST +if [[ -n "$SIGNING_IDENTITY" ]]; then + if [[ -n "$ENTITLEMENTS_PATH" && ! -f "$ENTITLEMENTS_PATH" ]]; then + echo "Missing entitlements file: $ENTITLEMENTS_PATH" >&2 + exit 1 + fi + + APP_ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH" + if [[ -n "$PROVISIONING_PROFILE" ]]; then + PROFILE_PLIST_PATH="$(mktemp "$DIST_DIR/profile.XXXXXX.plist")" + security cms -D -i "$PROVISIONING_PROFILE" > "$PROFILE_PLIST_PATH" + APP_IDENTIFIER="$("$PLISTBUDDY" -c "Print :Entitlements:com.apple.application-identifier" "$PROFILE_PLIST_PATH")" + TEAM_IDENTIFIER="$("$PLISTBUDDY" -c "Print :Entitlements:com.apple.developer.team-identifier" "$PROFILE_PLIST_PATH")" + + DERIVED_ENTITLEMENTS_PATH="$(mktemp "$DIST_DIR/entitlements.XXXXXX.plist")" + if [[ -n "$ENTITLEMENTS_PATH" ]]; then + cp "$ENTITLEMENTS_PATH" "$DERIVED_ENTITLEMENTS_PATH" + else + cat > "$DERIVED_ENTITLEMENTS_PATH" < + + + + +PLIST + fi + + set_plist_string "$DERIVED_ENTITLEMENTS_PATH" "com.apple.application-identifier" "$APP_IDENTIFIER" + set_plist_string "$DERIVED_ENTITLEMENTS_PATH" "com.apple.developer.team-identifier" "$TEAM_IDENTIFIER" + APP_ENTITLEMENTS_PATH="$DERIVED_ENTITLEMENTS_PATH" + fi + + CODESIGN_ARGS=(--force --sign "$SIGNING_IDENTITY") + if [[ "$CODESIGN_TIMESTAMP" != "0" ]]; then + CODESIGN_ARGS+=(--timestamp) + fi + if [[ -n "$CODESIGN_OPTIONS" ]]; then + CODESIGN_ARGS+=(--options "$CODESIGN_OPTIONS") + fi + if [[ -n "$APP_ENTITLEMENTS_PATH" ]]; then + CODESIGN_ARGS+=(--entitlements "$APP_ENTITLEMENTS_PATH") + fi + + codesign "${CODESIGN_ARGS[@]}" "$APP_DIR" + codesign --verify --strict --verbose=2 "$APP_DIR" +fi + echo "Built $APP_DIR" +du -sh "$APP_DIR" "$MACOS_DIR/$EXECUTABLE_NAME" "$RESOURCES_DIR/$ICON_NAME.icns" diff --git a/scripts/make-app-store-pkg.sh b/scripts/make-app-store-pkg.sh new file mode 100755 index 0000000..9887a83 --- /dev/null +++ b/scripts/make-app-store-pkg.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT_DIR/scripts/release-version.sh" + +APP_NAME="I Hate PDFs" +BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}" +APP_SIGNING_IDENTITY="${APP_SIGNING_IDENTITY:-}" +INSTALLER_SIGNING_IDENTITY="${INSTALLER_SIGNING_IDENTITY:-}" +PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}" +ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-$ROOT_DIR/Signing/IHatePDFs-AppStore.entitlements}" +DIST_DIR="$ROOT_DIR/dist" +APP_DIR="$DIST_DIR/$APP_NAME.app" +PKG_PATH="${PKG_PATH:-$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-appstore.pkg}" +VALIDATE_WITH_ALTOOL="${VALIDATE_WITH_ALTOOL:-0}" + +require_value() { + local name="$1" + local value="$2" + local hint="$3" + + if [[ -z "$value" ]]; then + echo "Missing $name." >&2 + echo "$hint" >&2 + exit 2 + fi +} + +require_value "APP_SIGNING_IDENTITY" "$APP_SIGNING_IDENTITY" \ + "Example: APP_SIGNING_IDENTITY=\"Apple Distribution: Your Name (TEAMID)\" or \"3rd Party Mac Developer Application: Your Name (TEAMID)\"" +require_value "INSTALLER_SIGNING_IDENTITY" "$INSTALLER_SIGNING_IDENTITY" \ + "Example: INSTALLER_SIGNING_IDENTITY=\"3rd Party Mac Developer Installer: Your Name (TEAMID)\"" +require_value "PROVISIONING_PROFILE" "$PROVISIONING_PROFILE" \ + "Download an App Store provisioning profile for $BUNDLE_ID and pass its local path." + +mkdir -p "$DIST_DIR" + +BUNDLE_ID="$BUNDLE_ID" \ +APP_VERSION="$APP_VERSION" \ +BUILD_NUMBER="$BUILD_NUMBER" \ +SIGNING_IDENTITY="$APP_SIGNING_IDENTITY" \ +ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH" \ +PROVISIONING_PROFILE="$PROVISIONING_PROFILE" \ +"$ROOT_DIR/scripts/build-app.sh" + +xattr -cr "$APP_DIR" 2>/dev/null || true +rm -f "$PKG_PATH" +productbuild \ + --component "$APP_DIR" /Applications \ + --sign "$INSTALLER_SIGNING_IDENTITY" \ + "$PKG_PATH" + +pkgutil --check-signature "$PKG_PATH" + +if [[ "$VALIDATE_WITH_ALTOOL" == "1" ]]; then + require_value "ASC_USERNAME" "${ASC_USERNAME:-}" \ + "Set ASC_USERNAME to the Apple ID or App Store Connect API key issuer format expected by altool." + require_value "ASC_PASSWORD" "${ASC_PASSWORD:-}" \ + "Set ASC_PASSWORD to an app-specific password or app-store-connect API key password." + + xcrun altool --validate-app \ + --type macos \ + --file "$PKG_PATH" \ + --username "$ASC_USERNAME" \ + --password "$ASC_PASSWORD" +fi + +echo "Created App Store package: $PKG_PATH" diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh index e42e7c7..b7e5a7f 100755 --- a/scripts/make-dmg.sh +++ b/scripts/make-dmg.sh @@ -2,14 +2,15 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT_DIR/scripts/release-version.sh" APP_NAME="I Hate PDFs" -RELEASE_VERSION="${RELEASE_VERSION:-0.2}" DIST_DIR="$ROOT_DIR/dist" APP_DIR="$DIST_DIR/$APP_NAME.app" DMG_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos.dmg" +BUILD_APP="${BUILD_APP:-1}" -if [[ ! -d "$APP_DIR" ]]; then - "$ROOT_DIR/scripts/build-app.sh" +if [[ "$BUILD_APP" != "0" || ! -d "$APP_DIR" ]]; then + APP_VERSION="$APP_VERSION" BUILD_NUMBER="$BUILD_NUMBER" "$ROOT_DIR/scripts/build-app.sh" fi rm -f "$DMG_PATH" diff --git a/scripts/release-version.sh b/scripts/release-version.sh new file mode 100755 index 0000000..53fc493 --- /dev/null +++ b/scripts/release-version.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +APP_VERSION="${APP_VERSION:-0.3.0}" +BUILD_NUMBER="${BUILD_NUMBER:-4}" +RELEASE_VERSION="${RELEASE_VERSION:-${APP_VERSION%.0}}" diff --git a/scripts/verify-pdf-annotations.swift b/scripts/verify-pdf-annotations.swift index 52d61e6..e9d1ad5 100644 --- a/scripts/verify-pdf-annotations.swift +++ b/scripts/verify-pdf-annotations.swift @@ -18,8 +18,9 @@ enum VerificationError: Error, CustomStringConvertible { case missingName(page: Int, index: Int, key: String) case missingString(page: Int, index: Int, key: String) case missingArray(page: Int, index: Int, key: String) - case missingPopup(page: Int, index: Int, subtype: String) case missingPopupParent(page: Int, index: Int) + case unexpectedMarkupPopup(page: Int, index: Int, subtype: String) + case unexpectedPopupLink(page: Int, index: Int, subtype: String) case missingExpectedSubtype(String) var description: String { @@ -36,10 +37,12 @@ enum VerificationError: Error, CustomStringConvertible { return "Annotation \(index) on page \(page) is missing string key /\(key)" case .missingArray(let page, let index, let key): return "Annotation \(index) on page \(page) is missing array key /\(key)" - case .missingPopup(let page, let index, let subtype): - return "\(subtype) annotation \(index) on page \(page) is missing a /Popup dictionary" case .missingPopupParent(let page, let index): return "Popup annotation \(index) on page \(page) is missing a /Parent dictionary" + case .unexpectedMarkupPopup(let page, let index, let subtype): + return "Popup annotation \(index) on page \(page) points at a /\(subtype) markup annotation; markup comments should export through /Contents" + case .unexpectedPopupLink(let page, let index, let subtype): + return "\(subtype) annotation \(index) on page \(page) should store comments in /Contents, not a /Popup link" case .missingExpectedSubtype(let subtype): return "Expected at least one /\(subtype) annotation" } @@ -85,7 +88,7 @@ for pageNumber in 1...document.numberOfPages { switch subtype { case "Highlight": try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex) - try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Highlight") + try rejectPopupLink(in: annotation, subtype: subtype, page: pageNumber, index: annotationIndex) if hasString(in: annotation, key: "IHatePDFsKind") { try requireString(in: annotation, key: "IHatePDFsKind", page: pageNumber, index: annotationIndex) summary.selectedTextComments += 1 @@ -95,7 +98,7 @@ for pageNumber in 1...document.numberOfPages { case "Underline": summary.underlines += 1 try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex) - try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Underline") + try rejectPopupLink(in: annotation, subtype: subtype, page: pageNumber, index: annotationIndex) case "Text": try requireTextKeys(in: annotation, page: pageNumber, index: annotationIndex) @@ -104,7 +107,6 @@ for pageNumber in 1...document.numberOfPages { try requireString(in: annotation, key: "IRT", page: pageNumber, index: annotationIndex) try requireString(in: annotation, key: "RT", page: pageNumber, index: annotationIndex) } else { - try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Text") summary.textNotes += 1 } case "FreeText": @@ -118,9 +120,24 @@ for pageNumber in 1...document.numberOfPages { case "Popup": summary.popups += 1 var parentDictionary: CGPDFDictionaryRef? - guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary) else { + guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary), + let parentDictionary + else { throw VerificationError.missingPopupParent(page: pageNumber, index: annotationIndex) } + let parentSubtype = try nameValue( + in: parentDictionary, + key: "Subtype", + page: pageNumber, + index: annotationIndex + ) + if parentSubtype == "Highlight" || parentSubtype == "Underline" { + throw VerificationError.unexpectedMarkupPopup( + page: pageNumber, + index: annotationIndex, + subtype: parentSubtype + ) + } default: continue } @@ -145,10 +162,6 @@ guard summary.replies > 0 else { guard summary.freeText > 0 else { throw VerificationError.missingExpectedSubtype("FreeText") } -guard summary.popups >= 4 else { - throw VerificationError.missingExpectedSubtype("Popup") -} - print("Verified raw PDF annotation dictionaries in \(inputURL.path): \(summary.highlights) highlight, \(summary.selectedTextComments) selected-text comment, \(summary.underlines) underline, \(summary.textNotes) text note, \(summary.replies) reply, \(summary.freeText) free-text, \(summary.popups) popups.") func annotationDictionary( @@ -251,6 +264,20 @@ func requireMarkupKeys( try requireString(in: dictionary, key: "M", page: page, index: index) } +func rejectPopupLink( + in dictionary: CGPDFDictionaryRef, + subtype: String, + page: Int, + index: Int +) throws { + var popupDictionary: CGPDFDictionaryRef? + guard CGPDFDictionaryGetDictionary(dictionary, "Popup", &popupDictionary) else { + return + } + + throw VerificationError.unexpectedPopupLink(page: page, index: index, subtype: subtype) +} + func requireTextKeys( in dictionary: CGPDFDictionaryRef, page: Int, @@ -262,22 +289,3 @@ func requireTextKeys( try requireString(in: dictionary, key: "T", page: page, index: index) try requireString(in: dictionary, key: "M", page: page, index: index) } - -func requirePopup( - in dictionary: CGPDFDictionaryRef, - page: Int, - index: Int, - subtype: String -) throws { - var popupDictionary: CGPDFDictionaryRef? - guard CGPDFDictionaryGetDictionary(dictionary, "Popup", &popupDictionary), - let popupDictionary - else { - throw VerificationError.missingPopup(page: page, index: index, subtype: subtype) - } - - let popupSubtype = try nameValue(in: popupDictionary, key: "Subtype", page: page, index: index) - guard popupSubtype == "Popup" else { - throw VerificationError.missingPopup(page: page, index: index, subtype: subtype) - } -} diff --git a/scripts/verify-sample-pdf.swift b/scripts/verify-sample-pdf.swift index 9d8a052..a2766f3 100644 --- a/scripts/verify-sample-pdf.swift +++ b/scripts/verify-sample-pdf.swift @@ -29,7 +29,6 @@ standardize( author: "Professor" ) page.addAnnotation(highlight) -addPopup(for: highlight, bounds: CGRect(x: 352, y: 592, width: 240, height: 110)) let selectedTextComment = PDFAnnotation( bounds: CGRect(x: 72, y: 594, width: 260, height: 22), @@ -42,12 +41,11 @@ selectedTextComment.quadrilateralPoints = quadPoints(width: 260, height: 22) standardize( selectedTextComment, name: "verify-selected-text-comment", - contents: "This selected-text comment is saved as standard PDF markup with popup contents.", + contents: "This selected-text comment is saved as standard parent annotation contents.", author: "Professor" ) _ = selectedTextComment.setValue("Comment", forAnnotationKey: PDFAnnotationKey(rawValue: "IHatePDFsKind")) page.addAnnotation(selectedTextComment) -addPopup(for: selectedTextComment, bounds: CGRect(x: 352, y: 472, width: 240, height: 110)) let underline = PDFAnnotation( bounds: CGRect(x: 72, y: 570, width: 260, height: 24), @@ -64,7 +62,6 @@ standardize( author: "Professor" ) page.addAnnotation(underline) -addPopup(for: underline, bounds: CGRect(x: 352, y: 540, width: 240, height: 110)) let textNote = PDFAnnotation( bounds: CGRect(x: 360, y: 620, width: 28, height: 28), @@ -153,18 +150,6 @@ func standardize( _ = annotation.setValue("Unmarked", forAnnotationKey: PDFAnnotationKey(rawValue: "State")) } -func addPopup(for annotation: PDFAnnotation, bounds: CGRect) { - let popup = PDFAnnotation(bounds: bounds, forType: .popup, withProperties: nil) - popup.contents = annotation.contents - popup.userName = annotation.userName - popup.modificationDate = annotation.modificationDate - popup.isOpen = false - popup.shouldDisplay = true - popup.shouldPrint = true - annotation.popup = popup - page.addAnnotation(popup) -} - func quadPoints(width: CGFloat, height: CGFloat) -> [NSValue] { [ NSValue(point: CGPoint(x: 0, y: height)),