v0.1 Comments and basic functionality work
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.DS_Store
|
||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
dist/
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcworkspace/xcuserdata/
|
||||||
|
*.xcodeproj/xcuserdata/
|
||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 I Hate PDFs contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
24
Package.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "IHatePDFs",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.executable(name: "IHatePDFs", targets: ["IHatePDFs"]),
|
||||||
|
.library(name: "IHatePDFsCore", targets: ["IHatePDFsCore"])
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "IHatePDFsCore"),
|
||||||
|
.executableTarget(
|
||||||
|
name: "IHatePDFs",
|
||||||
|
dependencies: ["IHatePDFsCore"]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "IHatePDFsCoreTests",
|
||||||
|
dependencies: ["IHatePDFsCore"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
121
README.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# I Hate PDFs
|
||||||
|
|
||||||
|
I Hate PDFs is an open-source macOS PDF reader for anyone who hates adobe. I think adobe is .
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This app is entirely vibe coded, but will somehow still be better than adobe acrobate soon.
|
||||||
|
|
||||||
|
Minimum supported macOS version: macOS 13 Ventura.
|
||||||
|
|
||||||
|
Supported Mac architectures: Apple Silicon and Intel, subject to the local Swift/Xcode toolchain used to build.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Open local `.pdf` files from disk.
|
||||||
|
- 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.
|
||||||
|
- Create free-text annotations directly on the page.
|
||||||
|
- Click annotations 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.
|
||||||
|
|
||||||
|
## Build From Source
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- macOS 13 or newer
|
||||||
|
- Xcode 15 or newer with command line tools
|
||||||
|
- Swift Package Manager
|
||||||
|
|
||||||
|
Build and run the debug executable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
swift run IHatePDFs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
swift test
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a release `.app` bundle:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Release app builds default to a universal `arm64` + `x86_64` executable. To build only the current architecture during development, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ARCHS="" scripts/build-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a downloadable `.dmg`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/make-dmg.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs.dmg`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Download `IHatePDFs.dmg`, open it, and move `I Hate PDFs.app` into `/Applications`. For local development builds that are not notarized, macOS may require opening the app from Finder with Control-click, Open the first time.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The project is a Swift Package with two targets:
|
||||||
|
|
||||||
|
- `IHatePDFsCore`: PDF annotation models and factory helpers.
|
||||||
|
- `IHatePDFs`: SwiftUI macOS app, PDFKit bridge, toolbar, menus, sidebars, anchored comment popovers, opening, saving, sharing, and search.
|
||||||
|
|
||||||
|
Useful checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
swift test
|
||||||
|
swift build -c release
|
||||||
|
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.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Screenshots live in `docs/screenshots`.
|
||||||
|
|
||||||
|
Current repository screenshots:
|
||||||
|
|
||||||
|
- `docs/screenshots/no-document.png`
|
||||||
|
- `docs/screenshots/default-reading.png`
|
||||||
|
- `docs/screenshots/highlight-comment-popover.png`
|
||||||
|
- `docs/screenshots/main-window.png`
|
||||||
|
- `docs/screenshots/comments-sidebar.png`
|
||||||
|
- `docs/screenshots/dark-mode-reading.png`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See `LICENSE`.
|
||||||
33
ROADMAP.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## Version 0.1
|
||||||
|
|
||||||
|
- Native macOS SwiftUI/PDFKit app.
|
||||||
|
- Local PDF opening.
|
||||||
|
- Reading controls: scrolling, zoom, fit width, fit page, page navigation, search.
|
||||||
|
- Focused default reading mode with optional page thumbnail sidebar.
|
||||||
|
- Highlight, underline, selection-bound comment, and free-text annotations.
|
||||||
|
- Anchored comment popovers from newly created selected-text comments, highlights, underlines, free text, and clicked annotations.
|
||||||
|
- Annotation list sidebar.
|
||||||
|
- Optional comments review sidebar with grouping, collapsed filtering, replies, and navigation.
|
||||||
|
- Save, Save As, and native macOS sharing with standard PDF annotation writing.
|
||||||
|
- `.app` and `.dmg` build scripts.
|
||||||
|
- Visual QA screenshots for empty, reading, popover, comments, and dark-mode states.
|
||||||
|
|
||||||
|
## Version 0.2
|
||||||
|
|
||||||
|
- More explicit visual selection handles for the active annotation.
|
||||||
|
- Better undo/redo integration for annotation edits.
|
||||||
|
- Optional author identity preferences.
|
||||||
|
- More granular sidebar and inspector layout memory for complex multi-window workflows.
|
||||||
|
- Fully standards-compliant reply-thread relationships through a lower-level PDF writer if PDFKit continues rejecting object-valued `/IRT`.
|
||||||
|
- Stronger interoperability test corpus covering Preview, Acrobat Reader, and browser PDF viewers.
|
||||||
|
- Import/export verification fixtures for existing annotated PDFs.
|
||||||
|
|
||||||
|
## Later
|
||||||
|
|
||||||
|
- Optional OCR for scanned readings.
|
||||||
|
- Optional citation metadata display.
|
||||||
|
- Optional AI summaries or question prompts.
|
||||||
|
- iPad companion app.
|
||||||
|
- LMS integrations.
|
||||||
982
Sources/IHatePDFs/AppState.swift
Normal file
@@ -0,0 +1,982 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import IHatePDFsCore
|
||||||
|
import PDFKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SidebarMode: String, CaseIterable, Identifiable {
|
||||||
|
case pages
|
||||||
|
case annotations
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CommentFilter: String, CaseIterable, Identifiable {
|
||||||
|
case all
|
||||||
|
case withComments
|
||||||
|
case withoutComments
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .all: return "All"
|
||||||
|
case .withComments: return "With Comments"
|
||||||
|
case .withoutComments: return "No Comment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnnotationPlacementTool: Equatable {
|
||||||
|
case freeText
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AppDefaults {
|
||||||
|
static let documentSidebarStates = "IHatePDFs.documentSidebarStates.v1"
|
||||||
|
|
||||||
|
static func sidebarPreference(for key: String) -> SidebarPreference? {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: documentSidebarStates),
|
||||||
|
let states = try? JSONDecoder().decode([String: SidebarPreference].self, from: data)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return states[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setSidebarPreference(_ preference: SidebarPreference, for key: String) {
|
||||||
|
let existingData = UserDefaults.standard.data(forKey: documentSidebarStates)
|
||||||
|
var states = existingData
|
||||||
|
.flatMap { try? JSONDecoder().decode([String: SidebarPreference].self, from: $0) }
|
||||||
|
?? [:]
|
||||||
|
states[key] = preference
|
||||||
|
|
||||||
|
guard let data = try? JSONEncoder().encode(states) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: documentSidebarStates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SidebarWidthBucket: String {
|
||||||
|
case compact
|
||||||
|
case regular
|
||||||
|
case wide
|
||||||
|
|
||||||
|
init(width: CGFloat) {
|
||||||
|
if width < 960 {
|
||||||
|
self = .compact
|
||||||
|
} else if width < 1280 {
|
||||||
|
self = .regular
|
||||||
|
} else {
|
||||||
|
self = .wide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SidebarPreference: Codable, Equatable {
|
||||||
|
var showLeftSidebar: Bool
|
||||||
|
var showCommentsSidebar: Bool
|
||||||
|
|
||||||
|
static let defaultReading = SidebarPreference(showLeftSidebar: false, showCommentsSidebar: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnnotationEditorContext: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let annotations: [PDFAnnotation]
|
||||||
|
let pages: [PDFPage]
|
||||||
|
let isNewAnnotation: Bool
|
||||||
|
let allowsDelete: Bool
|
||||||
|
let initialText: String
|
||||||
|
let initialAuthor: String
|
||||||
|
|
||||||
|
var primaryAnnotation: PDFAnnotation? { annotations.first }
|
||||||
|
var primaryPage: PDFPage? { pages.first }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppState: NSObject, ObservableObject {
|
||||||
|
@Published var document: PDFDocument?
|
||||||
|
@Published var documentURL: URL?
|
||||||
|
@Published var pdfView: PDFView?
|
||||||
|
@Published var annotations: [AnnotationSnapshot] = []
|
||||||
|
@Published var selectedAnnotationID: String?
|
||||||
|
@Published var activeEditor: AnnotationEditorContext?
|
||||||
|
@Published var placementTool: AnnotationPlacementTool?
|
||||||
|
@Published var showLeftSidebar = false {
|
||||||
|
didSet {
|
||||||
|
persistSidebarPreferenceIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var showCommentsSidebar = false {
|
||||||
|
didSet {
|
||||||
|
persistSidebarPreferenceIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var sidebarMode: SidebarMode = .pages
|
||||||
|
@Published var searchText = ""
|
||||||
|
@Published var showToolbarSearch = false
|
||||||
|
@Published var searchResults: [PDFSelection] = []
|
||||||
|
@Published var currentSearchIndex = 0
|
||||||
|
@Published var pageText = "1"
|
||||||
|
@Published var currentPageIndex = 0
|
||||||
|
@Published var commentSearchText = ""
|
||||||
|
@Published var commentFilter: CommentFilter = .all
|
||||||
|
@Published var selectedKindFilter: AcademicAnnotationKind?
|
||||||
|
@Published var selectedAuthorFilter = "All Authors"
|
||||||
|
@Published var selectedStatusFilter = ReviewState.allStatuses
|
||||||
|
@Published var collapsedPageIndexes: Set<Int> = []
|
||||||
|
@Published var statusMessage = "Open a PDF to begin."
|
||||||
|
|
||||||
|
private var pageObserver: NSObjectProtocol?
|
||||||
|
private var selectionObserver: NSObjectProtocol?
|
||||||
|
private var sidebarWidthBucket: SidebarWidthBucket = .regular
|
||||||
|
private var isApplyingSidebarPreference = false
|
||||||
|
private var hoveredAnnotationID: String?
|
||||||
|
|
||||||
|
var displayTitle: String {
|
||||||
|
documentURL?.lastPathComponent ?? "I Hate PDFs"
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageCount: Int {
|
||||||
|
document?.pageCount ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var authors: [String] {
|
||||||
|
let values = Set(annotations.map(\.author).filter { !$0.isEmpty })
|
||||||
|
return ["All Authors"] + values.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
var statuses: [String] {
|
||||||
|
let values = Set(annotations.map { ReviewState.label(for: $0.status) }.filter { !$0.isEmpty })
|
||||||
|
let preferred = [ReviewState.notReviewed, ReviewState.reviewed].filter(values.contains)
|
||||||
|
let custom = values.subtracting(preferred).sorted()
|
||||||
|
return [ReviewState.allStatuses] + preferred + custom
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredAnnotations: [AnnotationSnapshot] {
|
||||||
|
annotations.filter { item in
|
||||||
|
switch commentFilter {
|
||||||
|
case .all:
|
||||||
|
break
|
||||||
|
case .withComments:
|
||||||
|
guard item.hasComment else { return false }
|
||||||
|
case .withoutComments:
|
||||||
|
guard !item.hasComment else { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let selectedKindFilter, item.kind != selectedKindFilter {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedAuthorFilter != "All Authors", item.author != selectedAuthorFilter {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ReviewState.matches(item.status, filter: selectedStatusFilter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !query.isEmpty {
|
||||||
|
let haystack = [
|
||||||
|
item.contents,
|
||||||
|
item.author,
|
||||||
|
item.kind.displayName,
|
||||||
|
item.pageLabel
|
||||||
|
].joined(separator: " ")
|
||||||
|
guard haystack.localizedCaseInsensitiveContains(query) else { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var topLevelComments: [AnnotationSnapshot] {
|
||||||
|
filteredAnnotations.filter { !$0.isReply }
|
||||||
|
}
|
||||||
|
|
||||||
|
var repliesByParent: [String: [AnnotationSnapshot]] {
|
||||||
|
Dictionary(grouping: filteredAnnotations.filter(\.isReply), by: \.parentID!)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachPDFView(_ view: PDFView) {
|
||||||
|
if pdfView === view { return }
|
||||||
|
|
||||||
|
pdfView = view
|
||||||
|
configure(view)
|
||||||
|
view.document = document
|
||||||
|
|
||||||
|
pageObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .PDFViewPageChanged,
|
||||||
|
object: view,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.updateCurrentPageState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .PDFViewSelectionChanged,
|
||||||
|
object: view,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard self?.placementTool == nil else { return }
|
||||||
|
self?.statusMessage = "Selection ready for annotation."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWindowWidth(_ width: CGFloat) {
|
||||||
|
guard width.isFinite, width > 0 else { return }
|
||||||
|
|
||||||
|
let bucket = SidebarWidthBucket(width: width)
|
||||||
|
guard bucket != sidebarWidthBucket else { return }
|
||||||
|
|
||||||
|
sidebarWidthBucket = bucket
|
||||||
|
applySidebarPreferenceForCurrentDocument()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDocument() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.allowedContentTypes = [.pdf]
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.title = "Open PDF"
|
||||||
|
|
||||||
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||||
|
loadDocument(from: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDocument(from url: URL) {
|
||||||
|
guard let pdf = PDFDocument(url: url) else {
|
||||||
|
showAlert(title: "Unable to Open PDF", message: "The selected file could not be opened as a PDF.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document = pdf
|
||||||
|
documentURL = url
|
||||||
|
applySidebarPreferenceForCurrentDocument()
|
||||||
|
pdfView?.document = pdf
|
||||||
|
pdfView?.goToFirstPage(nil)
|
||||||
|
pageText = "1"
|
||||||
|
currentPageIndex = 0
|
||||||
|
searchText = ""
|
||||||
|
showToolbarSearch = false
|
||||||
|
searchResults = []
|
||||||
|
selectedAnnotationID = nil
|
||||||
|
activeEditor = nil
|
||||||
|
placementTool = nil
|
||||||
|
refreshAnnotations()
|
||||||
|
statusMessage = "Opened \(url.lastPathComponent)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeDocument() {
|
||||||
|
persistSidebarPreferenceIfNeeded()
|
||||||
|
document = nil
|
||||||
|
documentURL = nil
|
||||||
|
annotations = []
|
||||||
|
selectedAnnotationID = nil
|
||||||
|
activeEditor = nil
|
||||||
|
placementTool = nil
|
||||||
|
searchResults = []
|
||||||
|
searchText = ""
|
||||||
|
showToolbarSearch = false
|
||||||
|
pageText = "1"
|
||||||
|
currentPageIndex = 0
|
||||||
|
pdfView?.document = nil
|
||||||
|
applySidebarPreference(.defaultReading)
|
||||||
|
statusMessage = "Open a PDF to begin."
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDocument() {
|
||||||
|
guard let document else { return }
|
||||||
|
|
||||||
|
if let url = documentURL {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Overwrite Original PDF?"
|
||||||
|
alert.informativeText = "Annotations will be written directly into \(url.lastPathComponent). Use Save As to create a separate annotated copy."
|
||||||
|
alert.addButton(withTitle: "Save")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||||
|
|
||||||
|
write(document, to: url)
|
||||||
|
} else {
|
||||||
|
saveDocumentAs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDocumentAs() {
|
||||||
|
guard let document else { return }
|
||||||
|
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.allowedContentTypes = [.pdf]
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
panel.title = "Save Annotated PDF"
|
||||||
|
panel.nameFieldStringValue = suggestedAnnotatedFilename()
|
||||||
|
|
||||||
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||||
|
write(document, to: url)
|
||||||
|
documentURL = url
|
||||||
|
persistSidebarPreferenceIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareDocument() {
|
||||||
|
guard let document else { return }
|
||||||
|
guard let url = documentURL else {
|
||||||
|
saveDocumentAs()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .informational
|
||||||
|
alert.messageText = "Share Annotated PDF?"
|
||||||
|
alert.informativeText = "Save annotations to \(url.lastPathComponent) before sharing so recipients see the latest comments."
|
||||||
|
alert.addButton(withTitle: "Save and Share")
|
||||||
|
alert.addButton(withTitle: "Share Existing File")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
|
||||||
|
switch alert.runModal() {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
guard document.write(to: url) else {
|
||||||
|
showAlert(title: "Save Failed", message: "The PDF could not be written to \(url.path).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshAnnotations()
|
||||||
|
case .alertSecondButtonReturn:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
presentSharePicker(for: url)
|
||||||
|
statusMessage = "Ready to share \(url.lastPathComponent)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func addHighlight() {
|
||||||
|
addMarkup(style: .highlight, title: "Highlight Comment")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addUnderline() {
|
||||||
|
addMarkup(style: .underline, title: "Underline Comment")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addComment() {
|
||||||
|
addMarkup(style: .comment, title: "Comment")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFreeText() {
|
||||||
|
guard document != nil else {
|
||||||
|
statusMessage = "Open a PDF before adding free text."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeEditor = nil
|
||||||
|
placementTool = .freeText
|
||||||
|
statusMessage = "Click on the page to place free text."
|
||||||
|
}
|
||||||
|
|
||||||
|
func placePendingAnnotation(on page: PDFPage, near point: CGPoint) {
|
||||||
|
guard let placementTool else { return }
|
||||||
|
|
||||||
|
let insertion: AnnotationInsertion
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
switch placementTool {
|
||||||
|
case .freeText:
|
||||||
|
insertion = AnnotationFactory.freeTextInsertion(
|
||||||
|
on: page,
|
||||||
|
near: point,
|
||||||
|
text: "",
|
||||||
|
author: AnnotationFactory.defaultAuthor
|
||||||
|
)
|
||||||
|
title = "Free Text"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.placementTool = nil
|
||||||
|
add(insertion)
|
||||||
|
refreshAnnotations()
|
||||||
|
openEditor(
|
||||||
|
title: title,
|
||||||
|
annotations: [insertion.annotation],
|
||||||
|
pages: [page],
|
||||||
|
isNew: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addReply(to item: AnnotationSnapshot) {
|
||||||
|
let insertion = AnnotationFactory.replyInsertion(
|
||||||
|
to: item.annotation,
|
||||||
|
on: item.page,
|
||||||
|
comment: "",
|
||||||
|
author: AnnotationFactory.defaultAuthor,
|
||||||
|
parentID: item.id
|
||||||
|
)
|
||||||
|
add(insertion)
|
||||||
|
refreshAnnotations()
|
||||||
|
openEditor(
|
||||||
|
title: "Reply",
|
||||||
|
annotations: [insertion.annotation],
|
||||||
|
pages: [item.page],
|
||||||
|
isNew: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func edit(_ item: AnnotationSnapshot) {
|
||||||
|
openEditor(
|
||||||
|
title: item.kind == .freeText ? "Edit Free Text" : "Edit Comment",
|
||||||
|
annotations: [item.annotation],
|
||||||
|
pages: [item.page],
|
||||||
|
isNew: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ item: AnnotationSnapshot) {
|
||||||
|
let targets = annotations.filter { candidate in
|
||||||
|
candidate.id == item.id || candidate.parentID == item.id
|
||||||
|
}
|
||||||
|
let targetIDs = Set(targets.map(\.id))
|
||||||
|
|
||||||
|
for target in targets {
|
||||||
|
removeAnnotation(target.annotation, from: target.page)
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedAnnotationID.map(targetIDs.contains) == true {
|
||||||
|
selectedAnnotationID = nil
|
||||||
|
}
|
||||||
|
if hoveredAnnotationID.map(targetIDs.contains) == true {
|
||||||
|
hoveredAnnotationID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
activeEditor = nil
|
||||||
|
refreshAnnotations()
|
||||||
|
statusMessage = targets.count > 1 ? "Comment thread deleted." : "Comment deleted."
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleReviewed(_ item: AnnotationSnapshot) {
|
||||||
|
let isReviewed = ReviewState.isReviewed(item.status)
|
||||||
|
let nextState = isReviewed ? "Unmarked" : "Marked"
|
||||||
|
let date = Date()
|
||||||
|
|
||||||
|
item.annotation.modificationDate = date
|
||||||
|
_ = item.annotation.setValue(date, forAnnotationKey: .date)
|
||||||
|
_ = item.annotation.setValue(nextState, forAnnotationKey: AnnotationKeys.state)
|
||||||
|
_ = item.annotation.setValue("Marked", forAnnotationKey: AnnotationKeys.stateModel)
|
||||||
|
|
||||||
|
if let popup = item.annotation.popup {
|
||||||
|
popup.modificationDate = date
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfView?.annotationsChanged(on: item.page)
|
||||||
|
refreshAnnotations()
|
||||||
|
statusMessage = isReviewed ? "Marked as not reviewed." : "Marked as reviewed."
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveEditor(
|
||||||
|
_ context: AnnotationEditorContext,
|
||||||
|
text: String,
|
||||||
|
author: String
|
||||||
|
) {
|
||||||
|
updateAnnotations(in: context, text: text, author: author)
|
||||||
|
refreshAnnotations()
|
||||||
|
activeEditor = nil
|
||||||
|
statusMessage = "Comment saved."
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEditorDraft(
|
||||||
|
_ context: AnnotationEditorContext,
|
||||||
|
text: String,
|
||||||
|
author: String
|
||||||
|
) {
|
||||||
|
guard activeEditor?.id == context.id else { return }
|
||||||
|
updateAnnotations(in: context, text: text, author: author)
|
||||||
|
refreshAnnotations()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAnnotations(in context: AnnotationEditorContext) {
|
||||||
|
for (index, annotation) in context.annotations.enumerated() {
|
||||||
|
guard index < context.pages.count else { continue }
|
||||||
|
removeAnnotation(annotation, from: context.pages[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
activeEditor = nil
|
||||||
|
selectedAnnotationID = nil
|
||||||
|
refreshAnnotations()
|
||||||
|
statusMessage = "Annotation deleted."
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ item: AnnotationSnapshot) {
|
||||||
|
clearHoveredAnnotation()
|
||||||
|
clearHighlightedAnnotation()
|
||||||
|
selectedAnnotationID = item.id
|
||||||
|
item.annotation.isHighlighted = true
|
||||||
|
pdfView?.go(to: item.bounds.insetBy(dx: -24, dy: -24), on: item.page)
|
||||||
|
pdfView?.annotationsChanged(on: item.page)
|
||||||
|
statusMessage = "\(item.kind.displayName) on page \(item.pageLabel)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCommentHover(_ item: AnnotationSnapshot, isHovered: Bool) {
|
||||||
|
if isHovered {
|
||||||
|
clearHoveredAnnotation(except: item.id)
|
||||||
|
hoveredAnnotationID = item.id
|
||||||
|
item.annotation.isHighlighted = true
|
||||||
|
pdfView?.annotationsChanged(on: item.page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard hoveredAnnotationID == item.id else { return }
|
||||||
|
hoveredAnnotationID = nil
|
||||||
|
guard selectedAnnotationID != item.id else { return }
|
||||||
|
item.annotation.isHighlighted = false
|
||||||
|
pdfView?.annotationsChanged(on: item.page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAnnotationFromPDF(_ annotation: PDFAnnotation, page: PDFPage) {
|
||||||
|
let parent = AnnotationFactory.parentAnnotation(for: annotation)
|
||||||
|
let targetPage = parent.page ?? page
|
||||||
|
let pageIndex = document?.index(for: targetPage) ?? 0
|
||||||
|
let annotationIndex = targetPage.annotations.firstIndex(where: { $0 === parent }) ?? 0
|
||||||
|
let id = AnnotationKeys.stableID(
|
||||||
|
for: parent,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
annotationIndex: annotationIndex
|
||||||
|
)
|
||||||
|
if let item = annotations.first(where: { $0.id == id }) {
|
||||||
|
select(item)
|
||||||
|
edit(item)
|
||||||
|
} else {
|
||||||
|
openEditor(
|
||||||
|
title: "Edit Comment",
|
||||||
|
annotations: [parent],
|
||||||
|
pages: [targetPage],
|
||||||
|
isNew: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAnnotations() {
|
||||||
|
guard let document else {
|
||||||
|
annotations = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
annotations = AnnotationReader.snapshots(in: document)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSearch() {
|
||||||
|
guard let document else { return }
|
||||||
|
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
searchResults = []
|
||||||
|
pdfView?.highlightedSelections = nil
|
||||||
|
statusMessage = "Search cleared."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = document.findString(query, withOptions: [.caseInsensitive, .diacriticInsensitive])
|
||||||
|
for result in results {
|
||||||
|
result.color = NSColor.findHighlightColor.withAlphaComponent(0.45)
|
||||||
|
}
|
||||||
|
searchResults = results
|
||||||
|
pdfView?.highlightedSelections = results
|
||||||
|
currentSearchIndex = 0
|
||||||
|
goToSearchResult(at: currentSearchIndex)
|
||||||
|
statusMessage = results.isEmpty ? "No matches for \(query)." : "\(results.count) search matches."
|
||||||
|
}
|
||||||
|
|
||||||
|
func showSearch() {
|
||||||
|
guard document != nil else { return }
|
||||||
|
showToolbarSearch = true
|
||||||
|
statusMessage = "Search ready."
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.focusToolbarSearchField()
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
|
self?.focusToolbarSearchField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideSearch() {
|
||||||
|
showToolbarSearch = false
|
||||||
|
if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
searchResults = []
|
||||||
|
pdfView?.highlightedSelections = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextSearchResult() {
|
||||||
|
guard !searchResults.isEmpty else { return }
|
||||||
|
currentSearchIndex = (currentSearchIndex + 1) % searchResults.count
|
||||||
|
goToSearchResult(at: currentSearchIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousSearchResult() {
|
||||||
|
guard !searchResults.isEmpty else { return }
|
||||||
|
currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count
|
||||||
|
goToSearchResult(at: currentSearchIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoomIn() {
|
||||||
|
pdfView?.autoScales = false
|
||||||
|
pdfView?.zoomIn(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoomOut() {
|
||||||
|
pdfView?.autoScales = false
|
||||||
|
pdfView?.zoomOut(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fitWidth() {
|
||||||
|
pdfView?.displayMode = .singlePageContinuous
|
||||||
|
pdfView?.autoScales = true
|
||||||
|
statusMessage = "Fit to width."
|
||||||
|
}
|
||||||
|
|
||||||
|
func fitPage() {
|
||||||
|
pdfView?.displayMode = .singlePage
|
||||||
|
pdfView?.autoScales = true
|
||||||
|
statusMessage = "Fit to page."
|
||||||
|
}
|
||||||
|
|
||||||
|
func twoPageContinuous() {
|
||||||
|
pdfView?.displayMode = .twoUpContinuous
|
||||||
|
pdfView?.displayDirection = .vertical
|
||||||
|
pdfView?.displaysAsBook = false
|
||||||
|
pdfView?.autoScales = true
|
||||||
|
statusMessage = "Two pages continuous."
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToPageFromField() {
|
||||||
|
guard let document,
|
||||||
|
let target = Int(pageText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
|
target >= 1,
|
||||||
|
target <= document.pageCount,
|
||||||
|
let page = document.page(at: target - 1)
|
||||||
|
else {
|
||||||
|
updateCurrentPageState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(to: page, pageIndex: target - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToPreviousPage() {
|
||||||
|
guard let document, let currentPage = pdfView?.currentPage else { return }
|
||||||
|
let index = document.index(for: currentPage)
|
||||||
|
guard index != NSNotFound, index > 0, let page = document.page(at: index - 1) else {
|
||||||
|
updateCurrentPageState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(to: page, pageIndex: index - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToNextPage() {
|
||||||
|
guard let document, let currentPage = pdfView?.currentPage else { return }
|
||||||
|
let index = document.index(for: currentPage)
|
||||||
|
guard index != NSNotFound,
|
||||||
|
index + 1 < document.pageCount,
|
||||||
|
let page = document.page(at: index + 1)
|
||||||
|
else {
|
||||||
|
updateCurrentPageState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(to: page, pageIndex: index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFullScreen() {
|
||||||
|
NSApp.keyWindow?.toggleFullScreen(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimizeWindow() {
|
||||||
|
NSApp.keyWindow?.miniaturize(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addMarkup(style: MarkupAnnotationStyle, title: String) {
|
||||||
|
guard let selection = pdfView?.currentSelection, !selection.pages.isEmpty else {
|
||||||
|
statusMessage = style == .comment
|
||||||
|
? "Select text before adding a comment."
|
||||||
|
: "Select text before adding a markup annotation."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertions = AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: style,
|
||||||
|
comment: "",
|
||||||
|
author: AnnotationFactory.defaultAuthor
|
||||||
|
)
|
||||||
|
guard !insertions.isEmpty else {
|
||||||
|
statusMessage = "No selectable text was found in the selection."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for insertion in insertions {
|
||||||
|
add(insertion)
|
||||||
|
}
|
||||||
|
pdfView?.clearSelection()
|
||||||
|
refreshAnnotations()
|
||||||
|
openEditor(
|
||||||
|
title: title,
|
||||||
|
annotations: insertions.map(\.annotation),
|
||||||
|
pages: insertions.map(\.page),
|
||||||
|
isNew: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func add(_ insertion: AnnotationInsertion) {
|
||||||
|
insertion.page.addAnnotation(insertion.annotation)
|
||||||
|
if let popup = insertion.popup {
|
||||||
|
insertion.page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
pdfView?.annotationsChanged(on: insertion.page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAnnotations(
|
||||||
|
in context: AnnotationEditorContext,
|
||||||
|
text: String,
|
||||||
|
author: String
|
||||||
|
) {
|
||||||
|
let trimmedAuthor = author.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let authorValue = trimmedAuthor.isEmpty ? AnnotationFactory.defaultAuthor : trimmedAuthor
|
||||||
|
|
||||||
|
for (index, annotation) in context.annotations.enumerated() {
|
||||||
|
guard index < context.pages.count else { continue }
|
||||||
|
let page = context.pages[index]
|
||||||
|
let popup = AnnotationFactory.updateComment(
|
||||||
|
for: annotation,
|
||||||
|
on: page,
|
||||||
|
text: text,
|
||||||
|
author: authorValue
|
||||||
|
)
|
||||||
|
if let popup {
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
pdfView?.annotationsChanged(on: page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeAnnotation(_ annotation: PDFAnnotation, from page: PDFPage) {
|
||||||
|
let linkedPopups = page.annotations.filter { candidate in
|
||||||
|
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
|
||||||
|
return candidate === annotation.popup || AnnotationFactory.parentAnnotation(for: candidate) === annotation
|
||||||
|
}
|
||||||
|
|
||||||
|
for popup in linkedPopups {
|
||||||
|
page.removeAnnotation(popup)
|
||||||
|
}
|
||||||
|
if let popup = annotation.popup, popup.page != nil {
|
||||||
|
page.removeAnnotation(popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
page.removeAnnotation(annotation)
|
||||||
|
pdfView?.annotationsChanged(on: page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openEditor(
|
||||||
|
title: String,
|
||||||
|
annotations: [PDFAnnotation],
|
||||||
|
pages: [PDFPage],
|
||||||
|
isNew: Bool
|
||||||
|
) {
|
||||||
|
closeNativePopups(on: pages)
|
||||||
|
let first = annotations.first
|
||||||
|
activeEditor = AnnotationEditorContext(
|
||||||
|
title: title,
|
||||||
|
annotations: annotations,
|
||||||
|
pages: pages,
|
||||||
|
isNewAnnotation: isNew,
|
||||||
|
allowsDelete: true,
|
||||||
|
initialText: first?.contents ?? "",
|
||||||
|
initialAuthor: first?.userName ?? AnnotationFactory.defaultAuthor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeNativePopups(on pages: [PDFPage]) {
|
||||||
|
for page in pages {
|
||||||
|
for annotation in page.annotations {
|
||||||
|
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
|
||||||
|
annotation.isOpen = false
|
||||||
|
}
|
||||||
|
annotation.popup?.isOpen = false
|
||||||
|
}
|
||||||
|
pdfView?.annotationsChanged(on: page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure(_ view: PDFView) {
|
||||||
|
view.displayMode = .singlePageContinuous
|
||||||
|
view.displayDirection = .vertical
|
||||||
|
view.displaysPageBreaks = true
|
||||||
|
view.pageBreakMargins = NSEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
|
||||||
|
view.displayBox = .cropBox
|
||||||
|
view.autoScales = true
|
||||||
|
view.minScaleFactor = 0.25
|
||||||
|
view.maxScaleFactor = 6
|
||||||
|
view.interpolationQuality = .high
|
||||||
|
view.backgroundColor = NSColor.controlBackgroundColor
|
||||||
|
view.acceptsDraggedFiles = true
|
||||||
|
view.pageShadowsEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func write(_ document: PDFDocument, to url: URL) {
|
||||||
|
guard document.write(to: url) else {
|
||||||
|
showAlert(title: "Save Failed", message: "The PDF could not be written to \(url.path).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshAnnotations()
|
||||||
|
statusMessage = "Saved \(url.lastPathComponent)."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentSharePicker(for url: URL) {
|
||||||
|
guard let contentView = NSApp.keyWindow?.contentView else { return }
|
||||||
|
|
||||||
|
let anchor = NSRect(
|
||||||
|
x: contentView.bounds.maxX - 24,
|
||||||
|
y: contentView.bounds.maxY - 24,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
)
|
||||||
|
let picker = NSSharingServicePicker(items: [url])
|
||||||
|
picker.show(relativeTo: anchor, of: contentView, preferredEdge: .minY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func suggestedAnnotatedFilename() -> String {
|
||||||
|
guard let url = documentURL else { return "Annotated.pdf" }
|
||||||
|
let base = url.deletingPathExtension().lastPathComponent
|
||||||
|
return "\(base)-annotated.pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func goToSearchResult(at index: Int) {
|
||||||
|
guard searchResults.indices.contains(index), let pdfView else { return }
|
||||||
|
let selection = searchResults[index]
|
||||||
|
pdfView.setCurrentSelection(selection, animate: true)
|
||||||
|
pdfView.go(to: selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigate(to page: PDFPage, pageIndex: Int) {
|
||||||
|
guard let pdfView else { return }
|
||||||
|
|
||||||
|
let bounds = page.bounds(for: pdfView.displayBox)
|
||||||
|
let topSlice = NSRect(
|
||||||
|
x: bounds.minX,
|
||||||
|
y: bounds.maxY - 1,
|
||||||
|
width: bounds.width,
|
||||||
|
height: 1
|
||||||
|
)
|
||||||
|
pdfView.go(to: topSlice, on: page)
|
||||||
|
pdfView.setNeedsDisplay(pdfView.bounds)
|
||||||
|
|
||||||
|
currentPageIndex = pageIndex
|
||||||
|
pageText = "\(pageIndex + 1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCurrentPageState() {
|
||||||
|
guard let document, let currentPage = pdfView?.currentPage else { return }
|
||||||
|
let index = document.index(for: currentPage)
|
||||||
|
guard index != NSNotFound else { return }
|
||||||
|
currentPageIndex = index
|
||||||
|
pageText = "\(index + 1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearHighlightedAnnotation() {
|
||||||
|
guard let selectedAnnotationID,
|
||||||
|
let previous = annotations.first(where: { $0.id == selectedAnnotationID })
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previous.annotation.isHighlighted = false
|
||||||
|
pdfView?.annotationsChanged(on: previous.page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearHoveredAnnotation(except keptID: String? = nil) {
|
||||||
|
guard let hoveredAnnotationID,
|
||||||
|
hoveredAnnotationID != keptID,
|
||||||
|
hoveredAnnotationID != selectedAnnotationID,
|
||||||
|
let previous = annotations.first(where: { $0.id == hoveredAnnotationID })
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previous.annotation.isHighlighted = false
|
||||||
|
pdfView?.annotationsChanged(on: previous.page)
|
||||||
|
self.hoveredAnnotationID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showAlert(title: String, message: String) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func focusToolbarSearchField() {
|
||||||
|
guard let window = NSApp.keyWindow,
|
||||||
|
let root = window.contentView?.superview,
|
||||||
|
let field = findSearchField(in: root)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.makeFirstResponder(field)
|
||||||
|
field.selectText(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findSearchField(in view: NSView) -> NSTextField? {
|
||||||
|
if let field = view as? NSTextField,
|
||||||
|
field.placeholderString == "Search" {
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
for subview in view.subviews {
|
||||||
|
if let field = findSearchField(in: subview) {
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySidebarPreferenceForCurrentDocument() {
|
||||||
|
guard let documentURL else {
|
||||||
|
applySidebarPreference(.defaultReading)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let preference = AppDefaults.sidebarPreference(
|
||||||
|
for: sidebarPreferenceKey(for: documentURL, bucket: sidebarWidthBucket)
|
||||||
|
) ?? .defaultReading
|
||||||
|
applySidebarPreference(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySidebarPreference(_ preference: SidebarPreference) {
|
||||||
|
isApplyingSidebarPreference = true
|
||||||
|
showLeftSidebar = preference.showLeftSidebar
|
||||||
|
showCommentsSidebar = preference.showCommentsSidebar
|
||||||
|
isApplyingSidebarPreference = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistSidebarPreferenceIfNeeded() {
|
||||||
|
guard !isApplyingSidebarPreference,
|
||||||
|
let documentURL
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let preference = SidebarPreference(
|
||||||
|
showLeftSidebar: showLeftSidebar,
|
||||||
|
showCommentsSidebar: showCommentsSidebar
|
||||||
|
)
|
||||||
|
AppDefaults.setSidebarPreference(
|
||||||
|
preference,
|
||||||
|
for: sidebarPreferenceKey(for: documentURL, bucket: sidebarWidthBucket)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sidebarPreferenceKey(for url: URL, bucket: SidebarWidthBucket) -> String {
|
||||||
|
let documentKey = url.isFileURL ? url.standardizedFileURL.path : url.absoluteString
|
||||||
|
return "\(documentKey)#\(bucket.rawValue)"
|
||||||
|
}
|
||||||
|
}
|
||||||
160
Sources/IHatePDFs/CommentEditorView.swift
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import IHatePDFsCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CommentPopoverModel: ObservableObject {
|
||||||
|
let context: AnnotationEditorContext
|
||||||
|
|
||||||
|
@Published var text: String
|
||||||
|
@Published var author: String
|
||||||
|
|
||||||
|
private weak var appState: AppState?
|
||||||
|
private var didFinish = false
|
||||||
|
|
||||||
|
init(context: AnnotationEditorContext, appState: AppState) {
|
||||||
|
self.context = context
|
||||||
|
self.appState = appState
|
||||||
|
self.text = context.initialText
|
||||||
|
self.author = context.initialAuthor
|
||||||
|
}
|
||||||
|
|
||||||
|
func commit() {
|
||||||
|
guard !didFinish else { return }
|
||||||
|
didFinish = true
|
||||||
|
appState?.saveEditor(context, text: text, author: author)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() {
|
||||||
|
guard !didFinish else { return }
|
||||||
|
didFinish = true
|
||||||
|
appState?.deleteAnnotations(in: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDraft() {
|
||||||
|
guard !didFinish else { return }
|
||||||
|
appState?.updateEditorDraft(context, text: text, author: author)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CommentEditorView: View {
|
||||||
|
@ObservedObject var model: CommentPopoverModel
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@FocusState private var isCommentFocused: Bool
|
||||||
|
private let editorHorizontalInset: CGFloat = 9
|
||||||
|
private let editorVerticalInset: CGFloat = 7
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
header
|
||||||
|
commentField
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(width: 340)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.onAppear {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
isCommentFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: model.text) { _ in
|
||||||
|
model.updateDraft()
|
||||||
|
}
|
||||||
|
.onChange(of: model.author) { _ in
|
||||||
|
model.updateDraft()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: symbolName)
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
.frame(width: 16)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
model.commit()
|
||||||
|
} label: {
|
||||||
|
Label("Done", systemImage: "checkmark")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.keyboardShortcut(.return, modifiers: [.command])
|
||||||
|
.help("Done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var commentField: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
TextEditor(text: $model.text)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.focused($isCommentFocused)
|
||||||
|
.padding(.horizontal, editorHorizontalInset)
|
||||||
|
.padding(.vertical, editorVerticalInset)
|
||||||
|
|
||||||
|
if model.text.isEmpty {
|
||||||
|
Text("Add comment")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
|
||||||
|
.padding(.leading, editorHorizontalInset + 7)
|
||||||
|
.padding(.top, editorVerticalInset)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 118)
|
||||||
|
.background(InterfacePalette.fieldFill(for: colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Author", text: $model.author)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(InterfacePalette.fieldFill(for: colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.frame(width: 190)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if model.context.allowsDelete {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
model.delete()
|
||||||
|
} label: {
|
||||||
|
Label("Delete Annotation", systemImage: "trash")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.help("Delete Annotation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
model.context.title.replacingOccurrences(of: " Comment", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var symbolName: String {
|
||||||
|
guard let annotation = model.context.primaryAnnotation else {
|
||||||
|
return "text.bubble"
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = AcademicAnnotationKind(annotation: annotation)
|
||||||
|
return kind.symbolName
|
||||||
|
}
|
||||||
|
}
|
||||||
156
Sources/IHatePDFs/IHatePDFsApp.swift
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct IHatePDFsApp: App {
|
||||||
|
@StateObject private var appState = AppState()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
MainView()
|
||||||
|
.environmentObject(appState)
|
||||||
|
.onOpenURL { url in
|
||||||
|
appState.loadDocument(from: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.windowStyle(.titleBar)
|
||||||
|
.commands {
|
||||||
|
CommandGroup(replacing: .newItem) {
|
||||||
|
Button("Open...") {
|
||||||
|
appState.openDocument()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("o")
|
||||||
|
|
||||||
|
Button("Save") {
|
||||||
|
appState.saveDocument()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("s")
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Save As...") {
|
||||||
|
appState.saveDocumentAs()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Share...") {
|
||||||
|
appState.shareDocument()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("e", modifiers: [.command, .shift])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Close PDF") {
|
||||||
|
appState.closeDocument()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("w")
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandGroup(after: .textEditing) {
|
||||||
|
Button("Find in PDF") {
|
||||||
|
appState.showSearch()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("f")
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Find Next") {
|
||||||
|
appState.nextSearchResult()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("g")
|
||||||
|
.disabled(appState.searchResults.isEmpty)
|
||||||
|
|
||||||
|
Button("Find Previous") {
|
||||||
|
appState.previousSearchResult()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("g", modifiers: [.command, .shift])
|
||||||
|
.disabled(appState.searchResults.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandMenu("View") {
|
||||||
|
Button("Toggle Page Sidebar") {
|
||||||
|
appState.showLeftSidebar.toggle()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("0", modifiers: [.command, .option])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Toggle Comments Sidebar") {
|
||||||
|
appState.showCommentsSidebar.toggle()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("1", modifiers: [.command, .option])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Zoom In") {
|
||||||
|
appState.zoomIn()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("+")
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Zoom Out") {
|
||||||
|
appState.zoomOut()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("-")
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Fit to Width") {
|
||||||
|
appState.fitWidth()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("9", modifiers: [.command])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Fit to Page") {
|
||||||
|
appState.fitPage()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("8", modifiers: [.command])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Two Pages Continuous") {
|
||||||
|
appState.twoPageContinuous()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("7", modifiers: [.command])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandMenu("Annotate") {
|
||||||
|
Button("Highlight Selection") {
|
||||||
|
appState.addHighlight()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Underline Selection") {
|
||||||
|
appState.addUnderline()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("u", modifiers: [.command, .shift])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Comment on Selection") {
|
||||||
|
appState.addComment()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button("Add Free Text") {
|
||||||
|
appState.addFreeText()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("t", modifiers: [.command, .shift])
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandGroup(after: .windowArrangement) {
|
||||||
|
Button("Minimize") {
|
||||||
|
appState.minimizeWindow()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("m", modifiers: [.command])
|
||||||
|
|
||||||
|
Button("Toggle Full Screen") {
|
||||||
|
appState.toggleFullScreen()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("f", modifiers: [.command, .control])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Sources/IHatePDFs/InterfacePalette.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum InterfacePalette {
|
||||||
|
static func primaryText(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: .labelColor).opacity(scheme == .dark ? 0.88 : 0.86)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func secondaryText(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: .secondaryLabelColor).opacity(scheme == .dark ? 0.92 : 0.88)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func quietText(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: .tertiaryLabelColor).opacity(scheme == .dark ? 0.92 : 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func actionText(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: .controlAccentColor).opacity(scheme == .dark ? 0.78 : 0.72)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func subtleFill(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.045 : 0.026))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fieldFill(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.055 : 0.032))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hairline(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.12 : 0.095))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func connector(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.14 : 0.11))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func markerFill(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.055 : 0.035))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func markerStroke(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.16 : 0.13))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func selectedRowFill(for scheme: ColorScheme) -> Color {
|
||||||
|
Color(nsColor: .unemphasizedSelectedContentBackgroundColor)
|
||||||
|
.opacity(scheme == .dark ? 0.38 : 0.48)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func overlayBase(for scheme: ColorScheme) -> NSColor {
|
||||||
|
scheme == .dark ? .white : .black
|
||||||
|
}
|
||||||
|
}
|
||||||
303
Sources/IHatePDFs/MainView.swift
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import IHatePDFsCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainView: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
content
|
||||||
|
.onAppear {
|
||||||
|
appState.updateWindowWidth(proxy.size.width)
|
||||||
|
}
|
||||||
|
.onChange(of: proxy.size.width) { width in
|
||||||
|
appState.updateWindowWidth(width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(appState.displayTitle)
|
||||||
|
.frame(minWidth: 820, minHeight: 620)
|
||||||
|
.toolbar {
|
||||||
|
ReaderToolbar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var content: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if appState.document == nil {
|
||||||
|
EmptyDocumentView()
|
||||||
|
} else {
|
||||||
|
HSplitView {
|
||||||
|
if appState.showLeftSidebar {
|
||||||
|
LeftSidebarView()
|
||||||
|
.frame(minWidth: 170, idealWidth: 210, maxWidth: 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
PDFReaderView()
|
||||||
|
.frame(minWidth: 420)
|
||||||
|
|
||||||
|
if appState.showCommentsSidebar {
|
||||||
|
CommentsReviewSidebar()
|
||||||
|
.frame(minWidth: 260, idealWidth: 310, maxWidth: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusBarView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PDFReaderView: View {
|
||||||
|
var body: some View {
|
||||||
|
PDFKitRepresentedView()
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EmptyDocumentView: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "doc.richtext")
|
||||||
|
.font(.system(size: 48, weight: .regular))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Open a PDF")
|
||||||
|
.font(.title2)
|
||||||
|
|
||||||
|
Text("Use standard PDF annotations for selected-text comments, highlights, underlines, and free text.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.openDocument()
|
||||||
|
} label: {
|
||||||
|
Label("Open PDF", systemImage: "folder")
|
||||||
|
}
|
||||||
|
.keyboardShortcut("o")
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StatusBarView: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(appState.statusMessage)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if appState.document != nil {
|
||||||
|
Text("\(appState.annotations.count) annotations")
|
||||||
|
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 26)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReaderToolbar: ToolbarContent {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
|
var body: some ToolbarContent {
|
||||||
|
ToolbarItemGroup(placement: .navigation) {
|
||||||
|
Button {
|
||||||
|
appState.openDocument()
|
||||||
|
} label: {
|
||||||
|
Label("Open", systemImage: "folder")
|
||||||
|
}
|
||||||
|
.help("Open PDF")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.showLeftSidebar.toggle()
|
||||||
|
} label: {
|
||||||
|
Label("Pages", systemImage: "sidebar.left")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Toggle Page Sidebar")
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItemGroup(placement: .principal) {
|
||||||
|
Button {
|
||||||
|
appState.goToPreviousPage()
|
||||||
|
} label: {
|
||||||
|
Label("Previous Page", systemImage: "chevron.up")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Previous Page")
|
||||||
|
|
||||||
|
TextField("Page", text: $appState.pageText)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 52)
|
||||||
|
.onSubmit {
|
||||||
|
appState.goToPageFromField()
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Text("/ \(max(appState.pageCount, 1))")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.goToNextPage()
|
||||||
|
} label: {
|
||||||
|
Label("Next Page", systemImage: "chevron.down")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Next Page")
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItemGroup {
|
||||||
|
if appState.showToolbarSearch {
|
||||||
|
TextField("Search", text: $appState.searchText)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 150)
|
||||||
|
.focused($searchFocused)
|
||||||
|
.onSubmit {
|
||||||
|
appState.runSearch()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
searchFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.previousSearchResult()
|
||||||
|
} label: {
|
||||||
|
Label("Previous Match", systemImage: "chevron.left")
|
||||||
|
}
|
||||||
|
.disabled(appState.searchResults.isEmpty)
|
||||||
|
.help("Previous Search Match")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.nextSearchResult()
|
||||||
|
} label: {
|
||||||
|
Label("Next Match", systemImage: "chevron.right")
|
||||||
|
}
|
||||||
|
.disabled(appState.searchResults.isEmpty)
|
||||||
|
.help("Next Search Match")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.hideSearch()
|
||||||
|
} label: {
|
||||||
|
Label("Close Search", systemImage: "xmark")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Close Search")
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
appState.showSearch()
|
||||||
|
} label: {
|
||||||
|
Label("Search", systemImage: "magnifyingglass")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Button {
|
||||||
|
appState.addHighlight()
|
||||||
|
} label: {
|
||||||
|
Label("Highlight", systemImage: "highlighter")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Highlight Selection")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.addUnderline()
|
||||||
|
} label: {
|
||||||
|
Label("Underline", systemImage: "underline")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Underline Selection")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.addComment()
|
||||||
|
} label: {
|
||||||
|
Label("Comment", systemImage: "text.bubble")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Comment on Selection")
|
||||||
|
.help("Comment on Selection")
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Button {
|
||||||
|
appState.zoomOut()
|
||||||
|
} label: {
|
||||||
|
Label("Zoom Out", systemImage: "minus.magnifyingglass")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Zoom Out")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.zoomIn()
|
||||||
|
} label: {
|
||||||
|
Label("Zoom In", systemImage: "plus.magnifyingglass")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Zoom In")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.fitWidth()
|
||||||
|
} label: {
|
||||||
|
Label("Fit Width", systemImage: "arrow.left.and.right")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Fit to Width")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.fitPage()
|
||||||
|
} label: {
|
||||||
|
Label("Fit Page", systemImage: "arrow.up.left.and.down.right.magnifyingglass")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Fit Page")
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Button {
|
||||||
|
appState.saveDocument()
|
||||||
|
} label: {
|
||||||
|
Label("Save", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Save PDF")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.shareDocument()
|
||||||
|
} label: {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help("Share PDF")
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
appState.showCommentsSidebar.toggle()
|
||||||
|
} label: {
|
||||||
|
Label("Comments Sidebar", systemImage: "sidebar.right")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help(appState.showCommentsSidebar ? "Hide Comments Sidebar" : "Show Comments Sidebar")
|
||||||
|
.accessibilityLabel("Toggle Comments Sidebar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
365
Sources/IHatePDFs/PDFKitRepresentedView.swift
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import AppKit
|
||||||
|
import IHatePDFsCore
|
||||||
|
import PDFKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class AcademicPDFView: PDFView {
|
||||||
|
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
|
||||||
|
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
|
||||||
|
var onSelectionComment: (() -> Void)?
|
||||||
|
var onPreviousPageKey: (() -> Void)?
|
||||||
|
var onNextPageKey: (() -> Void)?
|
||||||
|
var placementTool: AnnotationPlacementTool? {
|
||||||
|
didSet {
|
||||||
|
guard oldValue != placementTool else { return }
|
||||||
|
window?.invalidateCursorRects(for: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
let point = convert(event.locationInWindow, from: nil)
|
||||||
|
|
||||||
|
if let page = page(for: point, nearest: false) {
|
||||||
|
let pagePoint = convert(point, to: page)
|
||||||
|
|
||||||
|
if placementTool != nil {
|
||||||
|
onPlacementClick?(page, pagePoint)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let annotation = editableAnnotation(on: page, at: pagePoint) {
|
||||||
|
closeNativePopups(on: page)
|
||||||
|
onAnnotationClick?(annotation, page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.mouseDown(with: event)
|
||||||
|
window?.makeFirstResponder(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func rightMouseDown(with event: NSEvent) {
|
||||||
|
guard hasCommentableSelection else {
|
||||||
|
super.rightMouseDown(with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu = commentMenu(from: super.menu(for: event))
|
||||||
|
NSMenu.popUpContextMenu(menu, with: event, for: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
|
||||||
|
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
|
||||||
|
super.keyDown(with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.keyCode {
|
||||||
|
case 123, 126:
|
||||||
|
onPreviousPageKey?()
|
||||||
|
case 124, 125:
|
||||||
|
onNextPageKey?()
|
||||||
|
default:
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resetCursorRects() {
|
||||||
|
super.resetCursorRects()
|
||||||
|
|
||||||
|
if placementTool != nil {
|
||||||
|
addCursorRect(bounds, cursor: .crosshair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func menu(for event: NSEvent) -> NSMenu? {
|
||||||
|
commentMenu(from: super.menu(for: event))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasCommentableSelection: Bool {
|
||||||
|
guard let selection = currentSelection,
|
||||||
|
!selection.pages.isEmpty,
|
||||||
|
selection.string?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commentMenu(from baseMenu: NSMenu?) -> NSMenu {
|
||||||
|
let menu = baseMenu ?? NSMenu()
|
||||||
|
guard hasCommentableSelection else { return menu }
|
||||||
|
guard !menu.items.contains(where: { $0.action == #selector(commentOnSelectionFromMenu(_:)) }) else {
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = NSMenuItem(
|
||||||
|
title: "Comment",
|
||||||
|
action: #selector(commentOnSelectionFromMenu(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
item.target = self
|
||||||
|
menu.insertItem(item, at: 0)
|
||||||
|
if menu.items.count > 1 {
|
||||||
|
menu.insertItem(.separator(), at: 1)
|
||||||
|
}
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func commentOnSelectionFromMenu(_ sender: Any?) {
|
||||||
|
onSelectionComment?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
|
||||||
|
if let direct = page.annotation(at: point),
|
||||||
|
let editable = editableParent(for: direct) {
|
||||||
|
return editable
|
||||||
|
}
|
||||||
|
|
||||||
|
for annotation in page.annotations.reversed() {
|
||||||
|
guard let editable = editableParent(for: annotation) 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),
|
||||||
|
editable.bounds.insetBy(dx: -24, dy: -24).contains(point) {
|
||||||
|
return editable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editableParent(for annotation: PDFAnnotation) -> PDFAnnotation? {
|
||||||
|
let parent = AnnotationFactory.parentAnnotation(for: annotation)
|
||||||
|
return isEditableAcademicAnnotation(parent) ? parent : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTextMarkup(_ annotation: PDFAnnotation) -> Bool {
|
||||||
|
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|
||||||
|
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeNativePopups(on page: PDFPage) {
|
||||||
|
for annotation in page.annotations {
|
||||||
|
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
|
||||||
|
annotation.isOpen = false
|
||||||
|
}
|
||||||
|
annotation.popup?.isOpen = false
|
||||||
|
}
|
||||||
|
annotationsChanged(on: page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
|
||||||
|
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|
||||||
|
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|
||||||
|
|| AnnotationKeys.annotation(annotation, hasSubtype: .text)
|
||||||
|
|| AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
|
||||||
|
|| AnnotationKeys.annotation(annotation, hasSubtype: .popup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PDFKitRepresentedView: NSViewRepresentable {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> AcademicPDFView {
|
||||||
|
let view = AcademicPDFView()
|
||||||
|
view.onAnnotationClick = { annotation, page in
|
||||||
|
Task { @MainActor in
|
||||||
|
appState.openAnnotationFromPDF(annotation, page: page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.onPlacementClick = { page, point in
|
||||||
|
Task { @MainActor in
|
||||||
|
appState.placePendingAnnotation(on: page, near: point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.onSelectionComment = {
|
||||||
|
Task { @MainActor in
|
||||||
|
appState.addComment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.onPreviousPageKey = {
|
||||||
|
Task { @MainActor in
|
||||||
|
appState.goToPreviousPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.onNextPageKey = {
|
||||||
|
Task { @MainActor in
|
||||||
|
appState.goToNextPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appState.attachPDFView(view)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ view: AcademicPDFView, context: Context) {
|
||||||
|
if view.document !== appState.document {
|
||||||
|
view.document = appState.document
|
||||||
|
}
|
||||||
|
view.placementTool = appState.placementTool
|
||||||
|
view.highlightedSelections = appState.searchResults.isEmpty ? nil : appState.searchResults
|
||||||
|
context.coordinator.sync(editor: appState.activeEditor, in: view, appState: appState)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class Coordinator: NSObject, NSPopoverDelegate {
|
||||||
|
private var popover: NSPopover?
|
||||||
|
private var model: CommentPopoverModel?
|
||||||
|
private var editorID: UUID?
|
||||||
|
private var isClosing = false
|
||||||
|
private weak var appState: AppState?
|
||||||
|
|
||||||
|
func sync(
|
||||||
|
editor context: AnnotationEditorContext?,
|
||||||
|
in view: AcademicPDFView,
|
||||||
|
appState: AppState
|
||||||
|
) {
|
||||||
|
self.appState = appState
|
||||||
|
|
||||||
|
guard let context else {
|
||||||
|
if !isClosing {
|
||||||
|
dismissCurrent(commit: false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if editorID == context.id, popover?.isShown == true {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissCurrent(commit: true)
|
||||||
|
show(context, in: view, appState: appState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func show(
|
||||||
|
_ context: AnnotationEditorContext,
|
||||||
|
in view: AcademicPDFView,
|
||||||
|
appState: AppState
|
||||||
|
) {
|
||||||
|
guard view.window != nil else { return }
|
||||||
|
|
||||||
|
let model = CommentPopoverModel(context: context, appState: appState)
|
||||||
|
let controller = NSHostingController(rootView: CommentEditorView(model: model))
|
||||||
|
let popover = NSPopover()
|
||||||
|
popover.behavior = .semitransient
|
||||||
|
popover.animates = true
|
||||||
|
popover.contentSize = NSSize(width: 340, height: 258)
|
||||||
|
popover.contentViewController = controller
|
||||||
|
popover.delegate = self
|
||||||
|
|
||||||
|
self.model = model
|
||||||
|
self.popover = popover
|
||||||
|
self.editorID = context.id
|
||||||
|
self.isClosing = false
|
||||||
|
|
||||||
|
let anchor = anchorRect(for: context, in: view)
|
||||||
|
popover.show(
|
||||||
|
relativeTo: anchor,
|
||||||
|
of: view,
|
||||||
|
preferredEdge: preferredEdge(for: anchor, in: view)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissCurrent(commit: Bool) {
|
||||||
|
guard let popover else {
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if commit {
|
||||||
|
model?.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
if popover.isShown {
|
||||||
|
popover.performClose(nil)
|
||||||
|
} else {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func popoverWillClose(_ notification: Notification) {
|
||||||
|
isClosing = true
|
||||||
|
model?.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func popoverDidClose(_ notification: Notification) {
|
||||||
|
let closedEditorID = editorID
|
||||||
|
let currentAppState = appState
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
if currentAppState?.activeEditor?.id == closedEditorID {
|
||||||
|
currentAppState?.activeEditor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanup() {
|
||||||
|
popover?.delegate = nil
|
||||||
|
popover = nil
|
||||||
|
model = nil
|
||||||
|
editorID = nil
|
||||||
|
isClosing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect {
|
||||||
|
guard let annotation = context.primaryAnnotation,
|
||||||
|
let page = context.primaryPage ?? annotation.page
|
||||||
|
else {
|
||||||
|
return centeredAnchor(in: view)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rect = view.convert(annotation.bounds, from: page).insetBy(dx: -4, dy: -4)
|
||||||
|
guard rect.width.isFinite,
|
||||||
|
rect.height.isFinite,
|
||||||
|
rect.width > 0,
|
||||||
|
rect.height > 0
|
||||||
|
else {
|
||||||
|
return centeredAnchor(in: view)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rect.intersection(view.bounds).isNull ? centeredAnchor(in: view) : rect
|
||||||
|
}
|
||||||
|
|
||||||
|
private func centeredAnchor(in view: AcademicPDFView) -> NSRect {
|
||||||
|
NSRect(x: view.bounds.midX - 1, y: view.bounds.midY - 1, width: 2, height: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preferredEdge(for anchor: NSRect, in view: AcademicPDFView) -> NSRectEdge {
|
||||||
|
anchor.midX > view.bounds.midX ? .minX : .maxX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PDFThumbnailRepresentedView: NSViewRepresentable {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> PDFThumbnailView {
|
||||||
|
let view = PDFThumbnailView()
|
||||||
|
view.thumbnailSize = CGSize(width: 88, height: 116)
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.maximumNumberOfColumns = 1
|
||||||
|
view.labelFont = NSFont.systemFont(ofSize: 11)
|
||||||
|
view.allowsDragging = false
|
||||||
|
view.pdfView = appState.pdfView
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ view: PDFThumbnailView, context: Context) {
|
||||||
|
view.pdfView = appState.pdfView
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Sources/IHatePDFs/ReviewState.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
enum ReviewState {
|
||||||
|
static let allStatuses = "All Statuses"
|
||||||
|
static let reviewed = "Reviewed"
|
||||||
|
static let notReviewed = "Not reviewed"
|
||||||
|
|
||||||
|
static func isReviewed(_ status: String) -> Bool {
|
||||||
|
status.localizedCaseInsensitiveCompare("Marked") == .orderedSame
|
||||||
|
|| status.localizedCaseInsensitiveCompare(reviewed) == .orderedSame
|
||||||
|
}
|
||||||
|
|
||||||
|
static func label(for status: String) -> String {
|
||||||
|
if isReviewed(status) { return reviewed }
|
||||||
|
return status.localizedCaseInsensitiveCompare("Unmarked") == .orderedSame
|
||||||
|
? notReviewed
|
||||||
|
: status
|
||||||
|
}
|
||||||
|
|
||||||
|
static func matches(_ status: String, filter: String) -> Bool {
|
||||||
|
switch filter {
|
||||||
|
case allStatuses:
|
||||||
|
return true
|
||||||
|
case reviewed:
|
||||||
|
return isReviewed(status)
|
||||||
|
case notReviewed:
|
||||||
|
return !isReviewed(status)
|
||||||
|
default:
|
||||||
|
return status == filter || label(for: status) == filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
593
Sources/IHatePDFs/SidebarViews.swift
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
import IHatePDFsCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LeftSidebarView: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Picker("Sidebar", selection: $appState.sidebarMode) {
|
||||||
|
Text("Pages").tag(SidebarMode.pages)
|
||||||
|
Text("Annotations").tag(SidebarMode.annotations)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
.padding(8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
switch appState.sidebarMode {
|
||||||
|
case .pages:
|
||||||
|
PDFThumbnailRepresentedView()
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
case .annotations:
|
||||||
|
AnnotationListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AnnotationListView: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(appState.annotations, selection: $appState.selectedAnnotationID) { item in
|
||||||
|
Button {
|
||||||
|
appState.select(item)
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: item.kind.symbolName)
|
||||||
|
.frame(width: 18)
|
||||||
|
.foregroundStyle(iconColor(for: item.kind))
|
||||||
|
.help(item.kind.displayName)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack {
|
||||||
|
Text(item.kind.displayName)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
Text("p. \(item.pageLabel)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.firstLine)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(item.hasComment ? .primary : .secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text(item.author)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(dateString(item.createdAt))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconColor(for kind: AcademicAnnotationKind) -> Color {
|
||||||
|
switch kind {
|
||||||
|
case .comment, .highlight, .note:
|
||||||
|
return Color(nsColor: .secondaryLabelColor)
|
||||||
|
case .underline, .reply:
|
||||||
|
return Color(nsColor: .tertiaryLabelColor)
|
||||||
|
case .freeText:
|
||||||
|
return Color(nsColor: .labelColor)
|
||||||
|
case .other:
|
||||||
|
return Color(nsColor: .tertiaryLabelColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dateString(_ date: Date?) -> String {
|
||||||
|
guard let date else { return "No date" }
|
||||||
|
return date.formatted(date: .abbreviated, time: .shortened)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CommentsReviewSidebar: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@State private var showsSearch = false
|
||||||
|
@State private var showsFilters = false
|
||||||
|
@State private var showsAdvancedFilters = false
|
||||||
|
|
||||||
|
private var groupedComments: [(pageIndex: Int, items: [AnnotationSnapshot])] {
|
||||||
|
let grouped = Dictionary(grouping: appState.topLevelComments, by: \.pageIndex)
|
||||||
|
return grouped
|
||||||
|
.map { (pageIndex: $0.key, items: $0.value) }
|
||||||
|
.sorted { $0.pageIndex < $1.pageIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
quickComment
|
||||||
|
if showsSearch || showsFilters {
|
||||||
|
Divider()
|
||||||
|
filters
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
commentList
|
||||||
|
}
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 9) {
|
||||||
|
Image(systemName: "text.bubble.fill")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
.help("Comments")
|
||||||
|
|
||||||
|
Text("Comments")
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text("\(appState.annotations.count)")
|
||||||
|
.font(.headline.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showsSearch.toggle()
|
||||||
|
} label: {
|
||||||
|
Label("Search Comments", systemImage: showsSearch ? "magnifyingglass.circle.fill" : "magnifyingglass")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.help("Search Comments")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showsFilters.toggle()
|
||||||
|
} label: {
|
||||||
|
Label("Filter Comments", systemImage: showsFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.help("Filter Comments")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var quickComment: some View {
|
||||||
|
Button {
|
||||||
|
appState.addComment()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "text.bubble")
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
.help("Comment on selected text")
|
||||||
|
|
||||||
|
Text("On selected text")
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(height: 36)
|
||||||
|
.background(InterfacePalette.subtleFill(for: colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.help("Select text, then add a comment")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filters: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if showsSearch {
|
||||||
|
TextField("Search comments", text: $appState.commentSearchText)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showsFilters {
|
||||||
|
Picker("Comment filter", selection: $appState.commentFilter) {
|
||||||
|
ForEach(CommentFilter.allCases) { filter in
|
||||||
|
Text(filter.title).tag(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
DisclosureGroup("More Filters", isExpanded: $showsAdvancedFilters) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Picker("Type", selection: Binding(
|
||||||
|
get: { appState.selectedKindFilter },
|
||||||
|
set: { appState.selectedKindFilter = $0 }
|
||||||
|
)) {
|
||||||
|
Text("All Types").tag(Optional<AcademicAnnotationKind>.none)
|
||||||
|
ForEach(AcademicAnnotationKind.allCases.filter { $0 != .other }) { kind in
|
||||||
|
Text(kind.displayName).tag(Optional(kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Author", selection: $appState.selectedAuthorFilter) {
|
||||||
|
ForEach(appState.authors, id: \.self) { author in
|
||||||
|
Text(author).tag(author)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Status", selection: $appState.selectedStatusFilter) {
|
||||||
|
ForEach(appState.statuses, id: \.self) { status in
|
||||||
|
Text(status).tag(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PageCommentGroup: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
let pageIndex: Int
|
||||||
|
let items: [AnnotationSnapshot]
|
||||||
|
let repliesByParent: [String: [AnnotationSnapshot]]
|
||||||
|
let showsPageHeader: Bool
|
||||||
|
|
||||||
|
private var isCollapsed: Bool {
|
||||||
|
showsPageHeader && appState.collapsedPageIndexes.contains(pageIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if showsPageHeader {
|
||||||
|
Button {
|
||||||
|
if isCollapsed {
|
||||||
|
appState.collapsedPageIndexes.remove(pageIndex)
|
||||||
|
} else {
|
||||||
|
appState.collapsedPageIndexes.insert(pageIndex)
|
||||||
|
}
|
||||||
|
} 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isCollapsed {
|
||||||
|
ForEach(items) { item in
|
||||||
|
let replies = repliesByParent[item.id] ?? []
|
||||||
|
CommentRow(item: item, replies: replies)
|
||||||
|
.id(([item.sidebarRenderID] + replies.map(\.sidebarRenderID)).joined(separator: "|"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CommentRow: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
let item: AnnotationSnapshot
|
||||||
|
let replies: [AnnotationSnapshot]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if !replies.isEmpty {
|
||||||
|
Rectangle()
|
||||||
|
.fill(InterfacePalette.connector(for: colorScheme))
|
||||||
|
.frame(width: 1)
|
||||||
|
.padding(.leading, 14)
|
||||||
|
.padding(.top, 30)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
parentComment
|
||||||
|
|
||||||
|
ForEach(replies) { reply in
|
||||||
|
ReplyRow(item: reply)
|
||||||
|
.id(reply.sidebarRenderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(InterfacePalette.hairline(for: colorScheme))
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var parentComment: some View {
|
||||||
|
HStack(alignment: .top, spacing: 9) {
|
||||||
|
CommentMarker(symbolName: item.kind.symbolName, size: 28, font: .caption)
|
||||||
|
.padding(.top, 1)
|
||||||
|
.help(item.kind == .reply ? "Reply" : "Comment Thread")
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Button {
|
||||||
|
appState.select(item)
|
||||||
|
} label: {
|
||||||
|
commentSummary
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityAction {
|
||||||
|
appState.select(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataRow(for: item)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Edit") {
|
||||||
|
appState.edit(item)
|
||||||
|
}
|
||||||
|
Button("Reply") {
|
||||||
|
appState.addReply(to: item)
|
||||||
|
}
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
appState.delete(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(InterfacePalette.actionText(for: colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
|
||||||
|
.onHover { isHovered in
|
||||||
|
appState.setCommentHover(item, isHovered: isHovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var commentSummary: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(item.author)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(dateString(item.modifiedAt ?? item.createdAt))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.contents.isEmpty ? "No text" : item.contents)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(item.contents.isEmpty ? InterfacePalette.quietText(for: colorScheme) : InterfacePalette.primaryText(for: colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metadataRow(for item: AnnotationSnapshot) -> some View {
|
||||||
|
HStack {
|
||||||
|
ReviewStatusChip(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dateString(_ date: Date?) -> String {
|
||||||
|
guard let date else { return "No date" }
|
||||||
|
return date.formatted(date: .abbreviated, time: .shortened)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AnnotationSnapshot {
|
||||||
|
var sidebarRenderID: String {
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
author,
|
||||||
|
contents,
|
||||||
|
status,
|
||||||
|
String(modifiedAt?.timeIntervalSinceReferenceDate ?? 0),
|
||||||
|
String(describing: bounds.minX),
|
||||||
|
String(describing: bounds.minY)
|
||||||
|
].joined(separator: "|")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CommentMarker: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
let symbolName: String
|
||||||
|
let size: CGFloat
|
||||||
|
let font: Font
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(InterfacePalette.markerFill(for: colorScheme))
|
||||||
|
Circle()
|
||||||
|
.stroke(InterfacePalette.markerStroke(for: colorScheme), lineWidth: 0.75)
|
||||||
|
Image(systemName: symbolName)
|
||||||
|
.font(font)
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.background(.bar)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReviewStatusChip: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
let item: AnnotationSnapshot
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
appState.toggleReviewed(item)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if isReviewed {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.caption2.weight(.bold))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(background)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(isReviewed ? "Mark as not reviewed" : "Mark as reviewed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isReviewed: Bool {
|
||||||
|
ReviewState.isReviewed(item.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var label: String {
|
||||||
|
ReviewState.label(for: item.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foreground: Color {
|
||||||
|
isReviewed
|
||||||
|
? InterfacePalette.actionText(for: colorScheme)
|
||||||
|
: InterfacePalette.quietText(for: colorScheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var background: Color {
|
||||||
|
if isReviewed {
|
||||||
|
return Color(nsColor: .controlAccentColor).opacity(colorScheme == .dark ? 0.16 : 0.11)
|
||||||
|
}
|
||||||
|
return InterfacePalette.subtleFill(for: colorScheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReplyRow: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
let item: AnnotationSnapshot
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
CommentMarker(symbolName: "text.bubble", size: 22, font: .caption2)
|
||||||
|
.frame(width: 28, alignment: .center)
|
||||||
|
.padding(.top, 7)
|
||||||
|
.help("Reply")
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Button {
|
||||||
|
appState.select(item)
|
||||||
|
} label: {
|
||||||
|
replySummary
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityAction {
|
||||||
|
appState.select(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
replyMetadataRow
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Edit") {
|
||||||
|
appState.edit(item)
|
||||||
|
}
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
appState.delete(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(InterfacePalette.actionText(for: colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
|
||||||
|
.onHover { isHovered in
|
||||||
|
appState.setCommentHover(item, isHovered: isHovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var replySummary: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(item.author)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(dateString(item.modifiedAt ?? item.createdAt))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.contents.isEmpty ? "No reply text" : item.contents)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(item.contents.isEmpty ? InterfacePalette.quietText(for: colorScheme) : InterfacePalette.primaryText(for: colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var replyMetadataRow: some View {
|
||||||
|
HStack {
|
||||||
|
ReviewStatusChip(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dateString(_ date: Date?) -> String {
|
||||||
|
guard let date else { return "No date" }
|
||||||
|
return date.formatted(date: .abbreviated, time: .shortened)
|
||||||
|
}
|
||||||
|
}
|
||||||
361
Sources/IHatePDFsCore/AnnotationFactory.swift
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import PDFKit
|
||||||
|
|
||||||
|
public enum AcademicAnnotationPalette {
|
||||||
|
public static let comment = NSColor(
|
||||||
|
calibratedRed: 0.88,
|
||||||
|
green: 0.72,
|
||||||
|
blue: 0.46,
|
||||||
|
alpha: 0.10
|
||||||
|
)
|
||||||
|
public static let highlight = NSColor(
|
||||||
|
calibratedRed: 0.88,
|
||||||
|
green: 0.72,
|
||||||
|
blue: 0.46,
|
||||||
|
alpha: 0.24
|
||||||
|
)
|
||||||
|
public static let underline = NSColor(
|
||||||
|
calibratedRed: 0.48,
|
||||||
|
green: 0.53,
|
||||||
|
blue: 0.62,
|
||||||
|
alpha: 0.56
|
||||||
|
)
|
||||||
|
public static let note = NSColor(
|
||||||
|
calibratedRed: 0.64,
|
||||||
|
green: 0.59,
|
||||||
|
blue: 0.49,
|
||||||
|
alpha: 0.9
|
||||||
|
)
|
||||||
|
public static let reply = NSColor(
|
||||||
|
calibratedRed: 0.52,
|
||||||
|
green: 0.58,
|
||||||
|
blue: 0.60,
|
||||||
|
alpha: 0.88
|
||||||
|
)
|
||||||
|
public static let freeTextFill = NSColor(
|
||||||
|
calibratedRed: 0.91,
|
||||||
|
green: 0.86,
|
||||||
|
blue: 0.75,
|
||||||
|
alpha: 0.32
|
||||||
|
)
|
||||||
|
public static let freeTextInk = NSColor(
|
||||||
|
calibratedWhite: 0.22,
|
||||||
|
alpha: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MarkupAnnotationStyle {
|
||||||
|
case comment
|
||||||
|
case highlight
|
||||||
|
case underline
|
||||||
|
|
||||||
|
var subtype: PDFAnnotationSubtype {
|
||||||
|
switch self {
|
||||||
|
case .comment: return .highlight
|
||||||
|
case .highlight: return .highlight
|
||||||
|
case .underline: return .underline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: NSColor {
|
||||||
|
switch self {
|
||||||
|
case .comment: return AcademicAnnotationPalette.comment
|
||||||
|
case .highlight: return AcademicAnnotationPalette.highlight
|
||||||
|
case .underline: return AcademicAnnotationPalette.underline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var markupType: PDFMarkupType {
|
||||||
|
switch self {
|
||||||
|
case .comment: return .highlight
|
||||||
|
case .highlight: return .highlight
|
||||||
|
case .underline: return .underline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnnotationInsertion {
|
||||||
|
public let page: PDFPage
|
||||||
|
public let annotation: PDFAnnotation
|
||||||
|
public let popup: PDFAnnotation?
|
||||||
|
|
||||||
|
public init(page: PDFPage, annotation: PDFAnnotation, popup: PDFAnnotation?) {
|
||||||
|
self.page = page
|
||||||
|
self.annotation = annotation
|
||||||
|
self.popup = popup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnnotationFactory {
|
||||||
|
public static let defaultAuthor = NSFullUserName().isEmpty ? NSUserName() : NSFullUserName()
|
||||||
|
|
||||||
|
public static func markupInsertions(
|
||||||
|
from selection: PDFSelection,
|
||||||
|
style: MarkupAnnotationStyle,
|
||||||
|
comment: String,
|
||||||
|
author: String,
|
||||||
|
date: Date = Date()
|
||||||
|
) -> [AnnotationInsertion] {
|
||||||
|
let lineSelections = selection.selectionsByLine()
|
||||||
|
var groups: [(page: PDFPage, rects: [CGRect])] = []
|
||||||
|
|
||||||
|
for lineSelection in lineSelections {
|
||||||
|
for page in lineSelection.pages {
|
||||||
|
let rect = lineSelection.bounds(for: page).insetBy(dx: -1.5, dy: -1.0)
|
||||||
|
guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue }
|
||||||
|
|
||||||
|
if let index = groups.firstIndex(where: { $0.page === page }) {
|
||||||
|
groups[index].rects.append(rect)
|
||||||
|
} else {
|
||||||
|
groups.append((page: page, rects: [rect]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.compactMap { group in
|
||||||
|
guard let firstRect = group.rects.first else { return nil }
|
||||||
|
let unionRect = group.rects.dropFirst().reduce(firstRect) { partial, rect in
|
||||||
|
partial.union(rect)
|
||||||
|
}
|
||||||
|
let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil)
|
||||||
|
annotation.markupType = style.markupType
|
||||||
|
annotation.color = style.color
|
||||||
|
annotation.quadrilateralPoints = group.rects.flatMap { rect in
|
||||||
|
quadPoints(for: rect, relativeTo: unionRect)
|
||||||
|
}
|
||||||
|
standardize(annotation, comment: comment, author: author, date: date)
|
||||||
|
if style == .comment {
|
||||||
|
_ = annotation.setValue(AnnotationKeys.appKindComment, forAnnotationKey: AnnotationKeys.appKind)
|
||||||
|
}
|
||||||
|
let popup = makePopupIfNeeded(for: annotation, on: group.page, open: false)
|
||||||
|
return AnnotationInsertion(page: group.page, annotation: annotation, popup: popup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func noteInsertion(
|
||||||
|
on page: PDFPage,
|
||||||
|
near point: CGPoint,
|
||||||
|
comment: String,
|
||||||
|
author: String,
|
||||||
|
date: Date = Date()
|
||||||
|
) -> AnnotationInsertion {
|
||||||
|
let bounds = clampedRect(
|
||||||
|
desired: CGRect(x: point.x, y: point.y, width: 28, height: 28),
|
||||||
|
on: page,
|
||||||
|
fallbackSize: CGSize(width: 28, height: 28)
|
||||||
|
)
|
||||||
|
let annotation = PDFAnnotation(bounds: bounds, forType: .text, withProperties: nil)
|
||||||
|
annotation.color = AcademicAnnotationPalette.note
|
||||||
|
annotation.iconType = .note
|
||||||
|
standardize(annotation, comment: comment, author: author, date: date)
|
||||||
|
let popup = makePopupIfNeeded(for: annotation, on: page, open: false)
|
||||||
|
return AnnotationInsertion(page: page, annotation: annotation, popup: popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func freeTextInsertion(
|
||||||
|
on page: PDFPage,
|
||||||
|
near point: CGPoint,
|
||||||
|
text: String,
|
||||||
|
author: String,
|
||||||
|
date: Date = Date()
|
||||||
|
) -> AnnotationInsertion {
|
||||||
|
let bounds = clampedRect(
|
||||||
|
desired: CGRect(x: point.x - 120, y: point.y - 40, width: 240, height: 80),
|
||||||
|
on: page,
|
||||||
|
fallbackSize: CGSize(width: 240, height: 80)
|
||||||
|
)
|
||||||
|
let annotation = PDFAnnotation(bounds: bounds, forType: .freeText, withProperties: nil)
|
||||||
|
annotation.font = NSFont.systemFont(ofSize: 13)
|
||||||
|
annotation.fontColor = AcademicAnnotationPalette.freeTextInk
|
||||||
|
annotation.alignment = .left
|
||||||
|
annotation.color = AcademicAnnotationPalette.freeTextFill
|
||||||
|
|
||||||
|
let border = PDFBorder()
|
||||||
|
border.lineWidth = 0.75
|
||||||
|
annotation.border = border
|
||||||
|
|
||||||
|
standardize(annotation, comment: text, author: author, date: date)
|
||||||
|
return AnnotationInsertion(page: page, annotation: annotation, popup: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func replyInsertion(
|
||||||
|
to parent: PDFAnnotation,
|
||||||
|
on page: PDFPage,
|
||||||
|
comment: String,
|
||||||
|
author: String,
|
||||||
|
parentID: String? = nil,
|
||||||
|
date: Date = Date()
|
||||||
|
) -> AnnotationInsertion {
|
||||||
|
let parentBounds = parent.bounds
|
||||||
|
let targetPoint = CGPoint(
|
||||||
|
x: parentBounds.maxX + 16,
|
||||||
|
y: max(parentBounds.minY, parentBounds.midY - 12)
|
||||||
|
)
|
||||||
|
let bounds = clampedRect(
|
||||||
|
desired: CGRect(origin: targetPoint, size: CGSize(width: 24, height: 24)),
|
||||||
|
on: page,
|
||||||
|
fallbackSize: CGSize(width: 24, height: 24)
|
||||||
|
)
|
||||||
|
let annotation = PDFAnnotation(bounds: bounds, forType: .text, withProperties: nil)
|
||||||
|
annotation.color = AcademicAnnotationPalette.reply
|
||||||
|
annotation.iconType = .comment
|
||||||
|
standardize(annotation, comment: comment, author: author, date: date)
|
||||||
|
let parentIdentifier = parentID
|
||||||
|
?? parent.value(forAnnotationKey: .name) as? String
|
||||||
|
?? UUID().uuidString
|
||||||
|
_ = annotation.setValue(parentIdentifier, forAnnotationKey: AnnotationKeys.inReplyTo)
|
||||||
|
_ = annotation.setValue("R", forAnnotationKey: AnnotationKeys.replyType)
|
||||||
|
let popup = makePopupIfNeeded(for: annotation, on: page, open: false)
|
||||||
|
return AnnotationInsertion(page: page, annotation: annotation, popup: popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func updateComment(
|
||||||
|
for annotation: PDFAnnotation,
|
||||||
|
on page: PDFPage,
|
||||||
|
text: String,
|
||||||
|
author: String,
|
||||||
|
date: Date = Date()
|
||||||
|
) -> PDFAnnotation? {
|
||||||
|
annotation.contents = text
|
||||||
|
annotation.userName = author
|
||||||
|
annotation.modificationDate = date
|
||||||
|
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
|
||||||
|
_ = annotation.setValue(date, forAnnotationKey: .date)
|
||||||
|
if annotation.value(forAnnotationKey: AnnotationKeys.creationDate) == nil {
|
||||||
|
_ = annotation.setValue(
|
||||||
|
AnnotationKeys.pdfDateString(from: date),
|
||||||
|
forAnnotationKey: AnnotationKeys.creationDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if AnnotationKeys.annotation(annotation, hasSubtype: .freeText) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
if let popup = annotation.popup {
|
||||||
|
page.removeAnnotation(popup)
|
||||||
|
annotation.popup = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let popup = annotation.popup {
|
||||||
|
popup.contents = text
|
||||||
|
popup.userName = author
|
||||||
|
popup.modificationDate = date
|
||||||
|
popup.isOpen = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return makePopupIfNeeded(for: annotation, on: page, open: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func standardize(
|
||||||
|
_ annotation: PDFAnnotation,
|
||||||
|
comment: String,
|
||||||
|
author: String,
|
||||||
|
date: Date
|
||||||
|
) {
|
||||||
|
annotation.contents = comment
|
||||||
|
annotation.userName = author
|
||||||
|
annotation.modificationDate = date
|
||||||
|
annotation.shouldDisplay = true
|
||||||
|
annotation.shouldPrint = true
|
||||||
|
_ = annotation.setValue(UUID().uuidString, forAnnotationKey: .name)
|
||||||
|
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
|
||||||
|
_ = annotation.setValue(date, forAnnotationKey: .date)
|
||||||
|
_ = annotation.setValue(
|
||||||
|
AnnotationKeys.pdfDateString(from: date),
|
||||||
|
forAnnotationKey: AnnotationKeys.creationDate
|
||||||
|
)
|
||||||
|
_ = annotation.setValue("Unmarked", forAnnotationKey: AnnotationKeys.state)
|
||||||
|
_ = annotation.setValue("Marked", forAnnotationKey: AnnotationKeys.stateModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func makePopupIfNeeded(
|
||||||
|
for annotation: PDFAnnotation,
|
||||||
|
on page: PDFPage,
|
||||||
|
open: Bool
|
||||||
|
) -> PDFAnnotation? {
|
||||||
|
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { return nil }
|
||||||
|
guard !AnnotationKeys.annotation(annotation, hasSubtype: .freeText) else { return nil }
|
||||||
|
guard let contents = annotation.contents,
|
||||||
|
!contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let popup = annotation.popup {
|
||||||
|
popup.contents = contents
|
||||||
|
popup.userName = annotation.userName
|
||||||
|
popup.modificationDate = annotation.modificationDate
|
||||||
|
popup.isOpen = open
|
||||||
|
popup.shouldDisplay = true
|
||||||
|
popup.shouldPrint = true
|
||||||
|
return popup.page == nil ? popup : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let popupBounds = popupRect(for: annotation.bounds, on: page)
|
||||||
|
let popup = PDFAnnotation(bounds: popupBounds, forType: .popup, withProperties: nil)
|
||||||
|
popup.contents = contents
|
||||||
|
popup.userName = annotation.userName
|
||||||
|
popup.modificationDate = annotation.modificationDate
|
||||||
|
popup.isOpen = open
|
||||||
|
popup.shouldDisplay = true
|
||||||
|
popup.shouldPrint = true
|
||||||
|
annotation.popup = popup
|
||||||
|
return popup
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func parentAnnotation(for annotation: PDFAnnotation) -> PDFAnnotation {
|
||||||
|
if AnnotationKeys.annotation(annotation, hasSubtype: .popup),
|
||||||
|
let parent = annotation.value(forAnnotationKey: .parent) as? PDFAnnotation {
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
return annotation
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func quadPoints(for rect: CGRect, relativeTo bounds: CGRect) -> [NSValue] {
|
||||||
|
let minX = rect.minX - bounds.minX
|
||||||
|
let maxX = rect.maxX - bounds.minX
|
||||||
|
let minY = rect.minY - bounds.minY
|
||||||
|
let maxY = rect.maxY - bounds.minY
|
||||||
|
|
||||||
|
return [
|
||||||
|
NSValue(point: CGPoint(x: minX, y: maxY)),
|
||||||
|
NSValue(point: CGPoint(x: maxX, y: maxY)),
|
||||||
|
NSValue(point: CGPoint(x: minX, y: minY)),
|
||||||
|
NSValue(point: CGPoint(x: maxX, y: minY))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func popupRect(for annotationBounds: CGRect, on page: PDFPage) -> CGRect {
|
||||||
|
let pageBounds = page.bounds(for: .cropBox)
|
||||||
|
let desired = CGRect(
|
||||||
|
x: annotationBounds.maxX + 10,
|
||||||
|
y: max(annotationBounds.minY - 96, pageBounds.minY + 12),
|
||||||
|
width: 240,
|
||||||
|
height: 120
|
||||||
|
)
|
||||||
|
return clampedRect(
|
||||||
|
desired: desired,
|
||||||
|
on: page,
|
||||||
|
fallbackSize: CGSize(width: 240, height: 120)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func clampedRect(
|
||||||
|
desired: CGRect,
|
||||||
|
on page: PDFPage,
|
||||||
|
fallbackSize: CGSize
|
||||||
|
) -> CGRect {
|
||||||
|
let pageBounds = page.bounds(for: .cropBox).insetBy(dx: 12, dy: 12)
|
||||||
|
let width = min(desired.width > 0 ? desired.width : fallbackSize.width, pageBounds.width)
|
||||||
|
let height = min(desired.height > 0 ? desired.height : fallbackSize.height, pageBounds.height)
|
||||||
|
let x = min(max(desired.minX, pageBounds.minX), pageBounds.maxX - width)
|
||||||
|
let y = min(max(desired.minY, pageBounds.minY), pageBounds.maxY - height)
|
||||||
|
return CGRect(x: x, y: y, width: width, height: height)
|
||||||
|
}
|
||||||
|
}
|
||||||
319
Sources/IHatePDFsCore/AnnotationModels.swift
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import PDFKit
|
||||||
|
|
||||||
|
public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
|
||||||
|
case comment
|
||||||
|
case highlight
|
||||||
|
case underline
|
||||||
|
case note
|
||||||
|
case freeText
|
||||||
|
case reply
|
||||||
|
case other
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
public init(annotation: PDFAnnotation) {
|
||||||
|
if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment {
|
||||||
|
self = .comment
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if AnnotationKeys.isReply(annotation) {
|
||||||
|
self = .reply
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) {
|
||||||
|
self = .highlight
|
||||||
|
} else if AnnotationKeys.annotation(annotation, hasSubtype: .underline) {
|
||||||
|
self = .underline
|
||||||
|
} else if AnnotationKeys.annotation(annotation, hasSubtype: .text) {
|
||||||
|
self = .note
|
||||||
|
} else if AnnotationKeys.annotation(annotation, hasSubtype: .freeText) {
|
||||||
|
self = .freeText
|
||||||
|
} else {
|
||||||
|
self = .other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .comment: return "Comment"
|
||||||
|
case .highlight: return "Highlight"
|
||||||
|
case .underline: return "Underline"
|
||||||
|
case .note: return "Note"
|
||||||
|
case .freeText: return "Free Text"
|
||||||
|
case .reply: return "Reply"
|
||||||
|
case .other: return "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .comment: return "text.bubble"
|
||||||
|
case .highlight: return "highlighter"
|
||||||
|
case .underline: return "underline"
|
||||||
|
case .note: return "note.text"
|
||||||
|
case .freeText: return "textformat"
|
||||||
|
case .reply: return "arrowshape.turn.up.left"
|
||||||
|
case .other: return "ellipsis"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||||
|
public let id: String
|
||||||
|
public let pageIndex: Int
|
||||||
|
public let pageLabel: String
|
||||||
|
public let annotationIndex: Int
|
||||||
|
public let kind: AcademicAnnotationKind
|
||||||
|
public let author: String
|
||||||
|
public let createdAt: Date?
|
||||||
|
public let modifiedAt: Date?
|
||||||
|
public let status: String
|
||||||
|
public let contents: String
|
||||||
|
public let bounds: CGRect
|
||||||
|
public let annotation: PDFAnnotation
|
||||||
|
public let page: PDFPage
|
||||||
|
public let parentID: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
pageIndex: Int,
|
||||||
|
pageLabel: String,
|
||||||
|
annotationIndex: Int,
|
||||||
|
kind: AcademicAnnotationKind,
|
||||||
|
author: String,
|
||||||
|
createdAt: Date?,
|
||||||
|
modifiedAt: Date?,
|
||||||
|
status: String,
|
||||||
|
contents: String,
|
||||||
|
bounds: CGRect,
|
||||||
|
annotation: PDFAnnotation,
|
||||||
|
page: PDFPage,
|
||||||
|
parentID: String?
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.pageIndex = pageIndex
|
||||||
|
self.pageLabel = pageLabel
|
||||||
|
self.annotationIndex = annotationIndex
|
||||||
|
self.kind = kind
|
||||||
|
self.author = author
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.modifiedAt = modifiedAt
|
||||||
|
self.status = status
|
||||||
|
self.contents = contents
|
||||||
|
self.bounds = bounds
|
||||||
|
self.annotation = annotation
|
||||||
|
self.page = page
|
||||||
|
self.parentID = parentID
|
||||||
|
}
|
||||||
|
|
||||||
|
public var firstLine: String {
|
||||||
|
let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let first = trimmed
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.first
|
||||||
|
.map(String.init)
|
||||||
|
else {
|
||||||
|
return "No comment"
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hasComment: Bool {
|
||||||
|
!contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isReply: Bool {
|
||||||
|
parentID != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func == (lhs: AnnotationSnapshot, rhs: AnnotationSnapshot) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
&& lhs.pageIndex == rhs.pageIndex
|
||||||
|
&& lhs.pageLabel == rhs.pageLabel
|
||||||
|
&& lhs.annotationIndex == rhs.annotationIndex
|
||||||
|
&& lhs.kind == rhs.kind
|
||||||
|
&& lhs.author == rhs.author
|
||||||
|
&& lhs.createdAt == rhs.createdAt
|
||||||
|
&& lhs.modifiedAt == rhs.modifiedAt
|
||||||
|
&& lhs.status == rhs.status
|
||||||
|
&& lhs.contents == rhs.contents
|
||||||
|
&& lhs.bounds == rhs.bounds
|
||||||
|
&& lhs.parentID == rhs.parentID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnnotationKeys {
|
||||||
|
public static let inReplyTo = PDFAnnotationKey(rawValue: "IRT")
|
||||||
|
public static let replyType = PDFAnnotationKey(rawValue: "RT")
|
||||||
|
public static let creationDate = PDFAnnotationKey(rawValue: "CreationDate")
|
||||||
|
public static let state = PDFAnnotationKey(rawValue: "State")
|
||||||
|
public static let stateModel = PDFAnnotationKey(rawValue: "StateModel")
|
||||||
|
public static let appKind = PDFAnnotationKey(rawValue: "IHatePDFsKind")
|
||||||
|
public static let appKindComment = "Comment"
|
||||||
|
|
||||||
|
public static func stableID(
|
||||||
|
for annotation: PDFAnnotation,
|
||||||
|
pageIndex: Int,
|
||||||
|
annotationIndex: Int
|
||||||
|
) -> String {
|
||||||
|
if let name = annotation.value(forAnnotationKey: .name) as? String, !name.isEmpty {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
let type = annotation.type ?? "Unknown"
|
||||||
|
let rect = annotation.bounds
|
||||||
|
return [
|
||||||
|
"page-\(pageIndex + 1)",
|
||||||
|
"annotation-\(annotationIndex)",
|
||||||
|
type,
|
||||||
|
String(format: "%.2f-%.2f-%.2f-%.2f", rect.minX, rect.minY, rect.width, rect.height)
|
||||||
|
].joined(separator: "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func parentID(
|
||||||
|
for annotation: PDFAnnotation,
|
||||||
|
document: PDFDocument?
|
||||||
|
) -> String? {
|
||||||
|
if let parentID = annotation.value(forAnnotationKey: inReplyTo) as? String,
|
||||||
|
!parentID.isEmpty {
|
||||||
|
return parentID
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let parent = annotation.value(forAnnotationKey: inReplyTo) as? PDFAnnotation else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let page = parent.page,
|
||||||
|
let document,
|
||||||
|
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 stableID(for: parent, pageIndex: pageIndex, annotationIndex: annotationIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isReply(_ annotation: PDFAnnotation) -> Bool {
|
||||||
|
annotation.value(forAnnotationKey: inReplyTo) is PDFAnnotation
|
||||||
|
|| annotation.value(forAnnotationKey: inReplyTo) is String
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func annotation(_ annotation: PDFAnnotation, hasSubtype subtype: PDFAnnotationSubtype) -> Bool {
|
||||||
|
guard let type = annotation.type else { return false }
|
||||||
|
let raw = subtype.rawValue
|
||||||
|
let normalized = raw.hasPrefix("/") ? String(raw.dropFirst()) : raw
|
||||||
|
return type == raw || type == normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func pdfDateString(from date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.calendar = Calendar(identifier: .gregorian)
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
formatter.dateFormat = "'D:'yyyyMMddHHmmss'Z00''00'''"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func dateValue(for key: PDFAnnotationKey, in annotation: PDFAnnotation) -> Date? {
|
||||||
|
if let date = annotation.value(forAnnotationKey: key) as? Date {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let value = annotation.value(forAnnotationKey: key) as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsePDFDate(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parsePDFDate(_ value: String) -> Date? {
|
||||||
|
let normalized = value
|
||||||
|
.replacingOccurrences(of: "Z00'00'", with: "Z")
|
||||||
|
.replacingOccurrences(of: "Z00\\'00\\'", with: "Z")
|
||||||
|
let formats = [
|
||||||
|
"'D:'yyyyMMddHHmmss'Z'",
|
||||||
|
"'D:'yyyyMMddHHmmss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssZ"
|
||||||
|
]
|
||||||
|
|
||||||
|
for format in formats {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.calendar = Calendar(identifier: .gregorian)
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
formatter.dateFormat = format
|
||||||
|
if let date = formatter.date(from: normalized) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnnotationReader {
|
||||||
|
public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] {
|
||||||
|
var result: [AnnotationSnapshot] = []
|
||||||
|
|
||||||
|
for pageIndex in 0..<document.pageCount {
|
||||||
|
guard let page = document.page(at: pageIndex) else { continue }
|
||||||
|
|
||||||
|
for (annotationIndex, annotation) in page.annotations.enumerated() {
|
||||||
|
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { continue }
|
||||||
|
|
||||||
|
let kind = AcademicAnnotationKind(annotation: annotation)
|
||||||
|
guard kind != .other || annotation.contents?.isEmpty == false 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 = AnnotationKeys.parentID(for: annotation, document: document)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
AnnotationSnapshot(
|
||||||
|
id: id,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
pageLabel: pageLabel,
|
||||||
|
annotationIndex: annotationIndex,
|
||||||
|
kind: kind,
|
||||||
|
author: author,
|
||||||
|
createdAt: createdAt,
|
||||||
|
modifiedAt: annotation.modificationDate,
|
||||||
|
status: status,
|
||||||
|
contents: annotation.contents ?? "",
|
||||||
|
bounds: annotation.bounds,
|
||||||
|
annotation: annotation,
|
||||||
|
page: page,
|
||||||
|
parentID: parentID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sorted { left, right in
|
||||||
|
if left.pageIndex != right.pageIndex {
|
||||||
|
return left.pageIndex < right.pageIndex
|
||||||
|
}
|
||||||
|
if left.bounds.maxY != right.bounds.maxY {
|
||||||
|
return left.bounds.maxY > right.bounds.maxY
|
||||||
|
}
|
||||||
|
return left.bounds.minX < right.bounds.minX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
313
Tests/IHatePDFsCoreTests/AnnotationFactoryTests.swift
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import XCTest
|
||||||
|
import AppKit
|
||||||
|
import PDFKit
|
||||||
|
@testable import IHatePDFsCore
|
||||||
|
|
||||||
|
final class AnnotationFactoryTests: XCTestCase {
|
||||||
|
func testHighlightSelectionRoundTripsThroughPDFSave() 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 insertions = AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .highlight,
|
||||||
|
comment: "Use this passage in lecture.",
|
||||||
|
author: "Professor"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(insertions.count, 1)
|
||||||
|
|
||||||
|
for insertion in insertions {
|
||||||
|
insertion.page.addAnnotation(insertion.annotation)
|
||||||
|
if let popup = insertion.popup {
|
||||||
|
insertion.page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension("pdf")
|
||||||
|
defer { try? FileManager.default.removeItem(at: outputURL) }
|
||||||
|
|
||||||
|
XCTAssertTrue(document.write(to: outputURL))
|
||||||
|
|
||||||
|
let reopened = try XCTUnwrap(PDFDocument(url: outputURL))
|
||||||
|
let reopenedPage = try XCTUnwrap(reopened.page(at: 0))
|
||||||
|
XCTAssertEqual(reopenedPage.string, "This is selectable academic text.")
|
||||||
|
XCTAssertTrue(
|
||||||
|
reopenedPage.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
||||||
|
&& $0.contents == "Use this passage in lecture."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectionBoundCommentRoundTripsAsCommentKind() 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 insertion = try XCTUnwrap(
|
||||||
|
AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .comment,
|
||||||
|
comment: "Explain this phrase.",
|
||||||
|
author: "Professor"
|
||||||
|
).first
|
||||||
|
)
|
||||||
|
|
||||||
|
page.addAnnotation(insertion.annotation)
|
||||||
|
if let popup = insertion.popup {
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
||||||
|
let reopenedComment = try XCTUnwrap(reopenedPage.annotations.first {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
||||||
|
&& $0.contents == "Explain this phrase."
|
||||||
|
})
|
||||||
|
|
||||||
|
XCTAssertEqual(AcademicAnnotationKind(annotation: reopenedComment), .comment)
|
||||||
|
XCTAssertEqual(
|
||||||
|
reopenedComment.value(forAnnotationKey: AnnotationKeys.appKind) as? String,
|
||||||
|
AnnotationKeys.appKindComment
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectionBoundCommentCreatedEmptyGetsPopupWhenTextIsSaved() 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 insertion = try XCTUnwrap(
|
||||||
|
AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .comment,
|
||||||
|
comment: "",
|
||||||
|
author: "Professor"
|
||||||
|
).first
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertNil(insertion.popup)
|
||||||
|
page.addAnnotation(insertion.annotation)
|
||||||
|
|
||||||
|
let popup = try XCTUnwrap(AnnotationFactory.updateComment(
|
||||||
|
for: insertion.annotation,
|
||||||
|
on: page,
|
||||||
|
text: "Explain this phrase.",
|
||||||
|
author: "Professor"
|
||||||
|
))
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
|
||||||
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
||||||
|
let reopenedComment = try XCTUnwrap(reopenedPage.annotations.first {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
||||||
|
&& $0.contents == "Explain this phrase."
|
||||||
|
})
|
||||||
|
|
||||||
|
XCTAssertEqual(AcademicAnnotationKind(annotation: reopenedComment), .comment)
|
||||||
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .popup)
|
||||||
|
&& $0.contents == "Explain this phrase."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAddingAnnotationPreservesPriorAnnotation() throws {
|
||||||
|
let document = try makeSelectableTextDocument()
|
||||||
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
|
|
||||||
|
let prior = AnnotationFactory.noteInsertion(
|
||||||
|
on: page,
|
||||||
|
near: CGPoint(x: 420, y: 700),
|
||||||
|
comment: "Existing note from another reader.",
|
||||||
|
author: "Colleague"
|
||||||
|
)
|
||||||
|
page.addAnnotation(prior.annotation)
|
||||||
|
if let popup = prior.popup {
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10)))
|
||||||
|
let highlight = try XCTUnwrap(
|
||||||
|
AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .highlight,
|
||||||
|
comment: "New professor comment.",
|
||||||
|
author: "Professor"
|
||||||
|
).first
|
||||||
|
)
|
||||||
|
page.addAnnotation(highlight.annotation)
|
||||||
|
if let popup = highlight.popup {
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
||||||
|
XCTAssertEqual(reopenedPage.string, "This is selectable academic text.")
|
||||||
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .text)
|
||||||
|
&& $0.contents == "Existing note from another reader."
|
||||||
|
})
|
||||||
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
||||||
|
&& $0.contents == "New professor comment."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testScannedImagePDFCanReceiveStandardTextAnnotation() throws {
|
||||||
|
let document = try makeImageOnlyDocument()
|
||||||
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
|
let insertion = AnnotationFactory.noteInsertion(
|
||||||
|
on: page,
|
||||||
|
near: CGPoint(x: 300, y: 500),
|
||||||
|
comment: "Comment on scanned reading.",
|
||||||
|
author: "Professor"
|
||||||
|
)
|
||||||
|
page.addAnnotation(insertion.annotation)
|
||||||
|
if let popup = insertion.popup {
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
||||||
|
XCTAssertNil(reopenedPage.string)
|
||||||
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .text)
|
||||||
|
&& $0.contents == "Comment on scanned reading."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFiveHundredPagePDFCanBeSavedWithAnnotation() throws {
|
||||||
|
let document = PDFDocument()
|
||||||
|
for index in 0..<501 {
|
||||||
|
document.insert(PDFPage(), at: index)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(document.pageCount, 501)
|
||||||
|
|
||||||
|
let page = try XCTUnwrap(document.page(at: 500))
|
||||||
|
let insertion = AnnotationFactory.noteInsertion(
|
||||||
|
on: page,
|
||||||
|
near: CGPoint(x: 120, y: 120),
|
||||||
|
comment: "End of long reading.",
|
||||||
|
author: "Professor"
|
||||||
|
)
|
||||||
|
page.addAnnotation(insertion.annotation)
|
||||||
|
if let popup = insertion.popup {
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reopened = try saveAndReopen(document)
|
||||||
|
XCTAssertEqual(reopened.pageCount, 501)
|
||||||
|
let reopenedPage = try XCTUnwrap(reopened.page(at: 500))
|
||||||
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .text)
|
||||||
|
&& $0.contents == "End of long reading."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTextAnnotationUsesStandardKeys() throws {
|
||||||
|
let page = PDFPage()
|
||||||
|
let insertion = AnnotationFactory.noteInsertion(
|
||||||
|
on: page,
|
||||||
|
near: CGPoint(x: 100, y: 100),
|
||||||
|
comment: "Discuss this claim in class.",
|
||||||
|
author: "Professor"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(AnnotationKeys.annotation(insertion.annotation, hasSubtype: .text))
|
||||||
|
XCTAssertEqual(insertion.annotation.contents, "Discuss this claim in class.")
|
||||||
|
XCTAssertEqual(insertion.annotation.userName, "Professor")
|
||||||
|
XCTAssertNotNil(insertion.annotation.value(forAnnotationKey: .name))
|
||||||
|
XCTAssertNotNil(insertion.annotation.value(forAnnotationKey: AnnotationKeys.creationDate))
|
||||||
|
XCTAssertEqual(insertion.annotation.value(forAnnotationKey: AnnotationKeys.state) as? String, "Unmarked")
|
||||||
|
XCTAssertNotNil(insertion.popup)
|
||||||
|
XCTAssertTrue(insertion.popup.map { AnnotationKeys.annotation($0, hasSubtype: .popup) } ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReplyStoresVisibleTextAnnotationWithBestEffortParentID() throws {
|
||||||
|
let page = PDFPage()
|
||||||
|
let parent = AnnotationFactory.noteInsertion(
|
||||||
|
on: page,
|
||||||
|
near: CGPoint(x: 100, y: 100),
|
||||||
|
comment: "Parent",
|
||||||
|
author: "Professor"
|
||||||
|
).annotation
|
||||||
|
let reply = AnnotationFactory.replyInsertion(
|
||||||
|
to: parent,
|
||||||
|
on: page,
|
||||||
|
comment: "Reply",
|
||||||
|
author: "Reader",
|
||||||
|
parentID: "parent-id"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(AnnotationKeys.annotation(reply.annotation, hasSubtype: .text))
|
||||||
|
XCTAssertEqual(reply.annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? String, "parent-id")
|
||||||
|
XCTAssertEqual(reply.annotation.value(forAnnotationKey: AnnotationKeys.replyType) as? String, "R")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFreeTextCreatesStandardFreeTextAnnotation() throws {
|
||||||
|
let page = PDFPage()
|
||||||
|
let insertion = AnnotationFactory.freeTextInsertion(
|
||||||
|
on: page,
|
||||||
|
near: CGPoint(x: 200, y: 200),
|
||||||
|
text: "Important definition",
|
||||||
|
author: "Professor"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(AnnotationKeys.annotation(insertion.annotation, hasSubtype: .freeText))
|
||||||
|
XCTAssertEqual(insertion.annotation.contents, "Important definition")
|
||||||
|
XCTAssertNil(insertion.popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSelectableTextDocument() throws -> PDFDocument {
|
||||||
|
let data = NSMutableData()
|
||||||
|
var mediaBox = CGRect(x: 0, y: 0, width: 612, height: 792)
|
||||||
|
let consumer = try XCTUnwrap(CGDataConsumer(data: data))
|
||||||
|
let context = try XCTUnwrap(CGContext(consumer: consumer, mediaBox: &mediaBox, nil))
|
||||||
|
|
||||||
|
context.beginPDFPage(nil)
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false)
|
||||||
|
let text = NSAttributedString(
|
||||||
|
string: "This is selectable academic text.",
|
||||||
|
attributes: [.font: NSFont.systemFont(ofSize: 18)]
|
||||||
|
)
|
||||||
|
text.draw(at: CGPoint(x: 72, y: 700))
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
context.endPDFPage()
|
||||||
|
context.closePDF()
|
||||||
|
|
||||||
|
return try XCTUnwrap(PDFDocument(data: data as Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeImageOnlyDocument() throws -> PDFDocument {
|
||||||
|
let image = NSImage(size: CGSize(width: 612, height: 792))
|
||||||
|
image.lockFocus()
|
||||||
|
NSColor.white.setFill()
|
||||||
|
NSRect(x: 0, y: 0, width: 612, height: 792).fill()
|
||||||
|
NSColor.darkGray.setStroke()
|
||||||
|
let path = NSBezierPath(rect: NSRect(x: 72, y: 580, width: 468, height: 80))
|
||||||
|
path.lineWidth = 2
|
||||||
|
path.stroke()
|
||||||
|
image.unlockFocus()
|
||||||
|
|
||||||
|
let document = PDFDocument()
|
||||||
|
document.insert(try XCTUnwrap(PDFPage(image: image)), at: 0)
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveAndReopen(_ document: PDFDocument) throws -> PDFDocument {
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension("pdf")
|
||||||
|
XCTAssertTrue(document.write(to: outputURL))
|
||||||
|
let reopened = try XCTUnwrap(PDFDocument(url: outputURL))
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
return reopened
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Optional {
|
||||||
|
func unwrap(
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) throws -> Wrapped {
|
||||||
|
try XCTUnwrap(self, file: file, line: line)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
docs/DESIGN_REVIEW.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# macOS Design Review
|
||||||
|
|
||||||
|
This review checks the current app against Apple's Human Interface Guidelines before a public release.
|
||||||
|
|
||||||
|
References:
|
||||||
|
|
||||||
|
- Apple HIG overview: https://developer.apple.com/design/human-interface-guidelines
|
||||||
|
- Designing for macOS: https://developer.apple.com/design/human-interface-guidelines/designing-for-macos
|
||||||
|
- Toolbars: https://developer.apple.com/design/human-interface-guidelines/toolbars
|
||||||
|
- Sidebars: https://developer.apple.com/design/human-interface-guidelines/sidebars
|
||||||
|
- Menus and the menu bar: https://developer.apple.com/design/human-interface-guidelines/menus and https://developer.apple.com/design/human-interface-guidelines/the-menu-bar
|
||||||
|
- Color: https://developer.apple.com/design/human-interface-guidelines/color
|
||||||
|
- Typography: https://developer.apple.com/design/human-interface-guidelines/typography
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Status: Pass for the current version 1 implementation direction, with manual visual QA still required on physical Intel and Apple Silicon Macs before a tagged release.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
- Platform fit: The app is macOS-only, targets macOS 13 or newer, uses SwiftUI/AppKit/PDFKit, and ships as a normal `.app` bundle inside a `.dmg`.
|
||||||
|
- Window and toolbar: Primary document controls live in the titlebar toolbar, grouped by opening/sharing, navigation, zoom, annotation, search, and saving.
|
||||||
|
- Menus and shortcuts: File, View, and Annotate commands are available through native command menus with standard keyboard shortcuts where appropriate.
|
||||||
|
- Sidebars: Page thumbnails, annotation list, and comments review are optional sidebars. The default open-PDF state is single-pane reading, and sidebars open only when requested or restored from user preference.
|
||||||
|
- Comments review: The comments sidebar uses a compact review-stream layout with a visible total count, add-comment affordance, collapsible page groups, hidden search/filter controls, and connected reply threads.
|
||||||
|
- Color and appearance: The UI uses system colors and materials, so light mode, dark mode, and automatic appearance inherit from macOS.
|
||||||
|
- Typography: Text uses system fonts and native SwiftUI controls; no custom brand typography is used in the reading interface.
|
||||||
|
- Reading focus: The PDF view remains the central, quiet surface; controls are compact and document-oriented.
|
||||||
|
- Accessibility basics: Native controls supply focus states and keyboard access; colors use system palettes with restrained highlight/note colors.
|
||||||
|
- Academic workflow: The open, select, highlight, comment, continue reading, save, and share path is available without accounts, sync, projects, or conversion.
|
||||||
|
|
||||||
|
## Release QA Still Required
|
||||||
|
|
||||||
|
- Run the app on both Apple Silicon and Intel hardware.
|
||||||
|
- Verify contrast and focus states in light and dark mode.
|
||||||
|
- Verify toolbar and sidebar behavior at narrow and wide window sizes.
|
||||||
|
- Verify keyboard-only operation for opening, searching, navigating, annotating, saving, and reviewing comments.
|
||||||
|
- Verify VoiceOver labels for toolbar buttons and sidebar controls.
|
||||||
72
docs/QA.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Manual PDF Interoperability QA
|
||||||
|
|
||||||
|
Run this checklist before tagging a public release.
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
Use at least:
|
||||||
|
|
||||||
|
- A selectable-text journal article.
|
||||||
|
- A scanned/image-only course reading.
|
||||||
|
- A long PDF near or above 500 pages.
|
||||||
|
- A PDF that already contains annotations from Preview or Acrobat.
|
||||||
|
- A PDF with bookmarks or an outline.
|
||||||
|
|
||||||
|
## 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, reply icons, 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.
|
||||||
|
|
||||||
|
## External Readers
|
||||||
|
|
||||||
|
Before manual reader checks, run the automated PDF structure checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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.
|
||||||
|
|
||||||
|
Open the saved annotated copy in:
|
||||||
|
|
||||||
|
- macOS Preview
|
||||||
|
- Adobe Acrobat Reader
|
||||||
|
- Safari, Chrome, and Firefox PDF viewers where annotations are supported
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- Highlighted text remains highlighted.
|
||||||
|
- Underlined text remains underlined.
|
||||||
|
- Selected-text comments remain attached to the referenced text.
|
||||||
|
- Highlight and selected-text comments can be opened.
|
||||||
|
- Free text remains visible on the page.
|
||||||
|
- Existing text, images, layout, bookmarks, and prior annotations remain intact.
|
||||||
|
|
||||||
|
## Visual QA Screenshots
|
||||||
|
|
||||||
|
Capture current screenshots in `docs/screenshots` for:
|
||||||
|
|
||||||
|
- `no-document.png`
|
||||||
|
- `default-reading.png`
|
||||||
|
- `highlight-comment-popover.png`
|
||||||
|
- `selected-text-comment-popover.png`
|
||||||
|
- `comments-sidebar.png`, including at least one reply thread with a visible connector line
|
||||||
|
- `dark-mode-reading.png`
|
||||||
|
|
||||||
|
## Known Version 1 Limitation
|
||||||
|
|
||||||
|
PDFKit rejects object-valued `/IRT` reply relationships through its public API. Replies created in this app are saved as visible standard `/Text` annotations, while full cross-reader reply-thread presentation must be verified and improved with a lower-level PDF writer if needed.
|
||||||
BIN
docs/screenshots/comments-sidebar.png
Normal file
|
After Width: | Height: | Size: 519 KiB |
22
docs/screenshots/comments-sidebar.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="920" viewBox="0 0 760 920" role="img" aria-label="I Hate PDFs comments sidebar screenshot">
|
||||||
|
<rect width="760" height="920" fill="#f5f5f7"/>
|
||||||
|
<rect x="80" y="60" width="600" height="800" rx="10" fill="#fbfbfd" stroke="#c9c9ce"/>
|
||||||
|
<text x="118" y="118" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="26" fill="#202124">Comments</text>
|
||||||
|
<text x="118" y="146" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" fill="#606166">4 total</text>
|
||||||
|
<rect x="118" y="176" width="524" height="34" rx="7" fill="#ffffff" stroke="#d1d1d6"/>
|
||||||
|
<text x="136" y="198" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#7a7a80">Search comments</text>
|
||||||
|
<rect x="118" y="242" width="524" height="1" fill="#d8d8dd"/>
|
||||||
|
<text x="118" y="282" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="17" font-weight="600" fill="#202124">Page 2</text>
|
||||||
|
<rect x="118" y="306" width="524" height="154" rx="8" fill="#ffffff" stroke="#d5d5da"/>
|
||||||
|
<text x="140" y="344" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600" fill="#202124">Highlight</text>
|
||||||
|
<text x="140" y="374" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Professor</text>
|
||||||
|
<text x="140" y="406" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">This is the core passage for Friday's discussion.</text>
|
||||||
|
<rect x="150" y="480" width="492" height="86" rx="8" fill="#f1f8f8" stroke="#c8dddd"/>
|
||||||
|
<text x="172" y="516" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" fill="#202124">Reply</text>
|
||||||
|
<text x="172" y="546" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Add this to the lecture notes.</text>
|
||||||
|
<text x="118" y="624" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="17" font-weight="600" fill="#202124">Page 5</text>
|
||||||
|
<rect x="118" y="648" width="524" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
|
||||||
|
<text x="140" y="686" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600" fill="#202124">Note</text>
|
||||||
|
<text x="140" y="716" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Professor</text>
|
||||||
|
<text x="140" y="748" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Compare this footnote with the appendix.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
BIN
docs/screenshots/dark-mode-reading.png
Normal file
|
After Width: | Height: | Size: 797 KiB |
BIN
docs/screenshots/default-reading.png
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
docs/screenshots/highlight-comment-popover.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
docs/screenshots/main-window.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
23
docs/screenshots/main-window.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="920" viewBox="0 0 1440 920" role="img" aria-label="I Hate PDFs main window screenshot">
|
||||||
|
<rect width="1440" height="920" fill="#f5f5f7"/>
|
||||||
|
<rect x="40" y="40" width="1360" height="840" rx="10" fill="#fbfbfd" stroke="#c9c9ce"/>
|
||||||
|
<rect x="40" y="40" width="1360" height="52" rx="10" fill="#ececf0"/>
|
||||||
|
<circle cx="68" cy="66" r="7" fill="#ff5f57"/>
|
||||||
|
<circle cx="92" cy="66" r="7" fill="#febc2e"/>
|
||||||
|
<circle cx="116" cy="66" r="7" fill="#28c840"/>
|
||||||
|
<rect x="64" y="116" width="210" height="724" fill="#f1f1f4"/>
|
||||||
|
<rect x="308" y="132" width="650" height="692" fill="#ffffff" stroke="#d1d1d6"/>
|
||||||
|
<rect x="358" y="190" width="550" height="14" fill="#d8d8dd"/>
|
||||||
|
<rect x="358" y="228" width="480" height="14" fill="#d8d8dd"/>
|
||||||
|
<rect x="358" y="266" width="520" height="14" fill="#f7dc55" opacity="0.75"/>
|
||||||
|
<rect x="358" y="304" width="470" height="14" fill="#d8d8dd"/>
|
||||||
|
<rect x="358" y="342" width="510" height="14" fill="#d8d8dd"/>
|
||||||
|
<rect x="996" y="116" width="364" height="724" fill="#f7f7f9"/>
|
||||||
|
<text x="1020" y="158" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" fill="#202124">Comments</text>
|
||||||
|
<rect x="1020" y="190" width="316" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
|
||||||
|
<text x="1040" y="226" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Highlight</text>
|
||||||
|
<text x="1040" y="254" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Discuss this claim in class.</text>
|
||||||
|
<rect x="1020" y="326" width="316" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
|
||||||
|
<text x="1040" y="362" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Note</text>
|
||||||
|
<text x="1040" y="390" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Connect to the seminar reading.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
BIN
docs/screenshots/no-document.png
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
docs/screenshots/preview-interoperability.png
Normal file
|
After Width: | Height: | Size: 756 KiB |
447
goal.md
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
# Project Goal
|
||||||
|
|
||||||
|
Build an open-source macOS desktop application for professors to read, annotate, and share academic PDFs using standard PDF annotations that remain visible and interactive when opened by other people in common PDF readers.
|
||||||
|
|
||||||
|
The application must let a professor open a local PDF, add comments attached to selected text, highlights, and underlines, save those annotations directly into the PDF file, and share the resulting PDF with students, colleagues, or publishers. The recipient must be able to see the annotations and open the associated comment popups without needing this application.
|
||||||
|
|
||||||
|
## Core Requirement
|
||||||
|
|
||||||
|
Annotations must be written as standards-compliant PDF annotations, not stored in a separate database, sidecar file, hidden metadata format, or app-specific layer.
|
||||||
|
|
||||||
|
A PDF annotated in this app must preserve its comments when opened in:
|
||||||
|
|
||||||
|
- macOS Preview
|
||||||
|
- Adobe Acrobat Reader
|
||||||
|
- Common browser PDF viewers where supported
|
||||||
|
|
||||||
|
## Primary Users
|
||||||
|
|
||||||
|
The primary user is a professor reading academic PDFs such as journal articles, book chapters, working papers, syllabi, dissertations, and scanned course readings.
|
||||||
|
|
||||||
|
The professor needs to:
|
||||||
|
|
||||||
|
- Read PDFs comfortably on macOS
|
||||||
|
- Highlight passages
|
||||||
|
- Attach explanatory comments to highlighted text
|
||||||
|
- Attach comments to selected text from the toolbar, comments sidebar, or right-click menu
|
||||||
|
- Save and share the annotated PDF
|
||||||
|
- Trust that another person can open the file and see the comments
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
The first version must run on macOS only.
|
||||||
|
|
||||||
|
Minimum supported version should be explicitly chosen before development. Recommended target:
|
||||||
|
|
||||||
|
- macOS 13 Ventura or newer
|
||||||
|
- Apple Silicon and Intel Macs
|
||||||
|
- Distributed as a downloadable `.dmg`
|
||||||
|
- Open-source repository with build instructions
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The project should use a permissive open-source license unless there is a specific reason not to. Recommended:
|
||||||
|
|
||||||
|
- MIT License or Apache 2.0
|
||||||
|
|
||||||
|
## Required Features
|
||||||
|
|
||||||
|
## Design and macOS User Experience
|
||||||
|
|
||||||
|
The app must feel like a polished, modern macOS application, not a generic cross-platform document viewer.
|
||||||
|
|
||||||
|
The user interface must follow Apple's Human Interface Guidelines for macOS where practical, including native-feeling window behavior, toolbar placement, sidebar behavior, menus, keyboard shortcuts, typography, spacing, focus states, and system color usage.
|
||||||
|
|
||||||
|
The app must be aesthetically restrained, calm, and pleasant for long academic reading sessions. The visual design should prioritize readability, focus, and low cognitive load over decorative styling.
|
||||||
|
|
||||||
|
The app must support:
|
||||||
|
|
||||||
|
- Light mode
|
||||||
|
- Dark mode
|
||||||
|
- Automatic appearance matching the user's macOS system setting
|
||||||
|
- Native macOS toolbar behavior
|
||||||
|
- Native macOS menu bar commands
|
||||||
|
- Native macOS keyboard shortcuts where applicable
|
||||||
|
- Smooth scrolling and zooming
|
||||||
|
- Crisp rendering on Retina displays
|
||||||
|
- Clear hover, selected, active, disabled, and focus states
|
||||||
|
- Accessible contrast for text, icons, controls, highlights, and annotation markers
|
||||||
|
- Subdued annotation colors that remain visible without looking like high-contrast demo markup
|
||||||
|
|
||||||
|
The app should use system typography, system colors, native controls, and familiar macOS interaction patterns unless there is a specific reason not to.
|
||||||
|
|
||||||
|
The PDF reading area must be visually quiet. Controls should not distract from the document. Toolbars, sidebars, popovers, and annotation panels must be useful, compact, and consistent with macOS conventions.
|
||||||
|
|
||||||
|
Annotation markers, comment icons, reply icons, and sidebar selection states must match the restrained macOS visual theme in both light mode and dark mode. They should be legible, but not harsh, neon, or visually louder than the PDF text.
|
||||||
|
|
||||||
|
The app must avoid:
|
||||||
|
|
||||||
|
- Cluttered toolbars
|
||||||
|
- Unnecessary branding inside the reading interface
|
||||||
|
- Bright or harsh color palettes
|
||||||
|
- Oversaturated comment/reply colors that clash with native macOS light or dark appearance
|
||||||
|
- Web-app-style controls that feel out of place on macOS
|
||||||
|
- Decorative gradients, oversized cards, or marketing-style layouts
|
||||||
|
- Custom UI patterns that conflict with standard macOS behavior
|
||||||
|
- Animations that slow down reading, annotation, or navigation
|
||||||
|
|
||||||
|
The app should feel appropriate for professors, researchers, graduate students, and academic professionals who spend long periods reading dense documents.
|
||||||
|
|
||||||
|
## Current UI Audit and Revised Design Direction
|
||||||
|
|
||||||
|
The current implementation is functionally promising but visually and interaction-wise off target. The screenshot evidence shows the app behaving more like a debug/admin interface around a PDF than a polished academic reading tool. The core PDF interoperability work remains valuable, but the user experience must be redesigned before the app can be considered complete.
|
||||||
|
|
||||||
|
### Current UI Problems
|
||||||
|
|
||||||
|
The current UI is wrong in these specific ways:
|
||||||
|
|
||||||
|
- Too much chrome is open by default. The page thumbnail sidebar and comments sidebar both appear immediately, leaving the PDF squeezed between panels.
|
||||||
|
- The default view does not prioritize reading. The PDF should dominate the window; sidebars should support the reading task, not frame the entire experience at all times.
|
||||||
|
- The comments sidebar is too dashboard-like. Search fields, segmented filters, type pickers, author pickers, status pickers, refresh buttons, and grouped metadata are all visible before the user asks for a review workflow.
|
||||||
|
- The app exposes a refresh button in the comments panel, which makes comments feel stale or manually synchronized. Comments must update live when annotations are created, edited, deleted, or selected.
|
||||||
|
- The comment creation flow feels like filling out a form. Adding a comment to selected text or a highlight should feel anchored, immediate, lightweight, and dismissible.
|
||||||
|
- It is unclear where to type a comment after selecting text. The app needs an obvious anchored comment popover, not a separate form-like editor whose relationship to the highlighted text is ambiguous.
|
||||||
|
- The highlight/comment workflow is too indirect. A professor should be able to select text, click highlight, immediately type a comment in context, press Command-Return or click away, and continue reading.
|
||||||
|
- The comments sidebar does not visibly update as part of the annotation action. A newly created or edited comment should appear immediately without requiring refresh.
|
||||||
|
- The PDF margins and surrounding gray space feel accidental and oversized. Page spacing should be tuned for reading: enough separation to orient the user, but not large dead zones.
|
||||||
|
- Toolbar controls are too dense and visually equal. Primary actions, reading controls, annotation tools, search, and save are competing for attention.
|
||||||
|
- Sidebars feel heavy. They use too much width and visual weight for routine reading.
|
||||||
|
- The right comments sidebar is open even when there is only one comment or no active review task.
|
||||||
|
- The left sidebar shows large empty vertical regions and thumbnail spacing that make the app feel unfinished.
|
||||||
|
- The annotation list and comments review views duplicate concepts without clear mode distinction.
|
||||||
|
- The selected text context menu can obscure the annotation workflow; the app should make its own annotation affordance more obvious than the system text context menu.
|
||||||
|
- The visible layout does not feel calm enough for long academic reading sessions.
|
||||||
|
|
||||||
|
### Revised Product Design Principle
|
||||||
|
|
||||||
|
The app is primarily a reading surface, not an annotation database.
|
||||||
|
|
||||||
|
The default experience after opening a PDF must be:
|
||||||
|
|
||||||
|
- A large, centered, comfortable PDF reading area.
|
||||||
|
- No right comments sidebar by default.
|
||||||
|
- No left thumbnail sidebar by default unless the user explicitly opens it or the window is wide enough and the user has previously enabled it.
|
||||||
|
- A compact native toolbar with only the most important controls visible.
|
||||||
|
- Annotation tools that are obvious but not visually dominant.
|
||||||
|
- Icon-only controls should explain their action on hover with native macOS help/tooltips.
|
||||||
|
- Toolbar icons should be visually distinct without stacked custom arrows or ambiguous overlays.
|
||||||
|
- A comments review panel that appears only when requested, when clicking a comments button, or when entering review mode.
|
||||||
|
- Automatic live updates everywhere; no manual refresh button for comments.
|
||||||
|
|
||||||
|
### Target First-Run and Open-PDF Layout
|
||||||
|
|
||||||
|
When no PDF is open:
|
||||||
|
|
||||||
|
- Show a quiet native empty state with one primary "Open PDF" action.
|
||||||
|
- Do not show disabled annotation controls as a dominant visual element.
|
||||||
|
- Keep the window simple and restrained.
|
||||||
|
|
||||||
|
When a PDF is open:
|
||||||
|
|
||||||
|
- The PDF occupies the center and most of the window.
|
||||||
|
- The default layout is single-pane reading mode.
|
||||||
|
- The toolbar contains compact groups:
|
||||||
|
- Open/save/share
|
||||||
|
- page navigation
|
||||||
|
- zoom/fit
|
||||||
|
- annotation tools
|
||||||
|
- search
|
||||||
|
- sidebar toggles
|
||||||
|
- Two-page continuous view should stay available from the View menu and keyboard shortcut, not as a persistent toolbar icon.
|
||||||
|
- The thumbnail sidebar is hidden by default and opens with a sidebar button or keyboard shortcut.
|
||||||
|
- The comments review sidebar is hidden by default and opens with a comments button or keyboard shortcut.
|
||||||
|
- The app remembers whether the user last had sidebars open for that document/window size.
|
||||||
|
- On narrow windows, opening one sidebar should not automatically force both sidebars into view.
|
||||||
|
|
||||||
|
### Target Commenting Interaction
|
||||||
|
|
||||||
|
Commenting must be immediate, anchored, and selection-based.
|
||||||
|
|
||||||
|
There must not be two primary kinds of comment posts. A user-facing comment is a message attached to selected PDF text or to an existing text markup annotation. The app must not present "Add a comment" as a loose page-marker creation flow that waits for the user to click somewhere on the page.
|
||||||
|
|
||||||
|
Required highlight comment flow:
|
||||||
|
|
||||||
|
1. The user selects text.
|
||||||
|
2. The user clicks Highlight or presses the highlight shortcut.
|
||||||
|
3. The text is highlighted immediately.
|
||||||
|
4. A small native popover appears near the highlight or in the nearest margin.
|
||||||
|
5. The popover contains a focused comment text area with placeholder text such as "Add comment".
|
||||||
|
6. The user types a comment.
|
||||||
|
7. The comment is saved automatically when the popover closes, when focus leaves the popover, or when the user presses Command-Return.
|
||||||
|
8. The comments sidebar, if open, updates immediately.
|
||||||
|
9. The user continues reading without navigating away from the PDF.
|
||||||
|
|
||||||
|
The user must not have to understand a separate form, save button, manual refresh button, or detached editor window just to attach a comment to highlighted text.
|
||||||
|
|
||||||
|
Required selected-text comment flow:
|
||||||
|
|
||||||
|
1. The user selects text with the mouse.
|
||||||
|
2. The user clicks Comment, uses the comments sidebar add-comment affordance, or right-clicks and chooses Comment.
|
||||||
|
3. The selected text receives a restrained standard PDF text markup annotation.
|
||||||
|
4. A small anchored popover opens immediately for the comment text.
|
||||||
|
5. The comment appears in the comments sidebar as one normal comment row, not as a separate post type.
|
||||||
|
6. Hovering the sidebar comment temporarily highlights the referenced text in the PDF without navigating away from the current reading position.
|
||||||
|
7. The comment saves automatically and remains standard PDF annotation contents.
|
||||||
|
|
||||||
|
Standalone page-placement comments are not a version 1 commenting flow. The app may preserve existing annotations from other readers and may use standard PDF text annotations for replies where PDFKit requires them, but the ordinary "add comment" path must be selection-bound.
|
||||||
|
|
||||||
|
Required edit flow:
|
||||||
|
|
||||||
|
- Clicking an existing highlight, underline, selection-bound comment, reply, or free-text annotation opens the anchored comment popover.
|
||||||
|
- Editing is inline and live-updating.
|
||||||
|
- Deleting an annotation is available from the popover through a clear but secondary destructive action.
|
||||||
|
- The comments sidebar can also edit comments, but it is not the primary creation experience.
|
||||||
|
|
||||||
|
### Target Comments Sidebar
|
||||||
|
|
||||||
|
The comments sidebar is a review mode, not the default annotation input surface.
|
||||||
|
|
||||||
|
The sidebar should read like a clean document-review conversation stream, closer to a native comments/chat inspector than a database table. Comments should be easy to scan in sequence, with a clear comment icon or author marker, author/time metadata, the full comment text, compact reply actions, and visible thread structure for replies.
|
||||||
|
|
||||||
|
It should:
|
||||||
|
|
||||||
|
- Be hidden by default.
|
||||||
|
- Open from a comments toolbar button, View menu command, or keyboard shortcut.
|
||||||
|
- Show a compact header with total comment count.
|
||||||
|
- Update automatically as annotations change.
|
||||||
|
- Never require or expose a manual refresh button in ordinary use.
|
||||||
|
- Group comments by page.
|
||||||
|
- Show author, date, review state, and full comment text.
|
||||||
|
- Use the row's circular marker icon to differentiate comments, highlights, underlines, and replies; do not duplicate that icon in the metadata row.
|
||||||
|
- Let users change review state directly from a compact Reviewed/Not reviewed chip in each comment row.
|
||||||
|
- Include search and filters, but hide advanced filters behind a compact filter menu or disclosure control.
|
||||||
|
- Keep replies visually subordinate to their parent comment.
|
||||||
|
- Draw subtle vertical connector lines that make reply threads visually clear, like a clean comments/chat section.
|
||||||
|
- Navigate to and select the associated PDF annotation when a parent comment or reply is clicked.
|
||||||
|
- Temporarily highlight the referenced PDF text when a comment row or reply row is hovered.
|
||||||
|
- Feel like a native macOS inspector/sidebar, not a web dashboard.
|
||||||
|
|
||||||
|
The comments sidebar may have a refresh command only as a hidden/debug or menu-level recovery action if PDFKit state becomes inconsistent. It must not be a primary visible control.
|
||||||
|
|
||||||
|
### Target Annotation Sidebar and Thumbnail Sidebar
|
||||||
|
|
||||||
|
The left sidebar must have clear purpose:
|
||||||
|
|
||||||
|
- Thumbnail mode is for navigation.
|
||||||
|
- Annotation list mode is for scanning annotations.
|
||||||
|
- The app should not show both the left navigation sidebar and right comments review sidebar by default.
|
||||||
|
- If both are open, widths must be compact and the PDF must remain the dominant visual element.
|
||||||
|
- Thumbnail spacing should be dense enough to feel native and useful.
|
||||||
|
- Empty sidebar regions should be avoided.
|
||||||
|
|
||||||
|
### Visual Density, Margins, and Reading Comfort
|
||||||
|
|
||||||
|
The PDF view should feel like a high-quality macOS document reader.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Page margins and inter-page spacing must be deliberately tuned.
|
||||||
|
- Fit-to-width should use available space efficiently without huge dead zones.
|
||||||
|
- Actual-size mode should not be the default if it creates awkward margins for common slides or articles.
|
||||||
|
- The background around pages should be a subtle system color, not a visually heavy gray field.
|
||||||
|
- Sidebars should use compact row spacing and native materials.
|
||||||
|
- Buttons should use icon-only labels where the meaning is standard, with accessibility labels and tooltips.
|
||||||
|
- Search should not consume excessive toolbar width.
|
||||||
|
- Annotation colors should be readable and restrained.
|
||||||
|
- There should be no visible decorative branding inside the reading interface.
|
||||||
|
|
||||||
|
### Path to Fixing the Current UI
|
||||||
|
|
||||||
|
The UI must be fixed in this order:
|
||||||
|
|
||||||
|
1. Change the default open-PDF layout to single-pane reading mode with both sidebars hidden.
|
||||||
|
2. Add a compact comments button that toggles the comments review sidebar.
|
||||||
|
3. Remove the visible comments refresh button and make annotation changes update the sidebar automatically.
|
||||||
|
4. Replace the form-like comment editor with an anchored popover tied to the selected highlight, selection-bound comment, underline, or free-text annotation.
|
||||||
|
5. Make highlight creation open the comment popover immediately after creating the PDF annotation.
|
||||||
|
6. Make selected-text comment creation available from the toolbar, comments sidebar, and right-click menu, then open the anchored comment popover immediately.
|
||||||
|
7. Tune PDF page margins, page-break spacing, and fit behavior so documents use available space well.
|
||||||
|
8. Simplify and regroup the toolbar around reading and annotation tasks.
|
||||||
|
9. Make advanced comment filters collapsible or menu-based.
|
||||||
|
10. Redesign comment rows to feel like a clean threaded review/chat stream, including visible connector lines for replies and click-to-navigate behavior on comments and replies.
|
||||||
|
11. Reduce sidebar widths and row spacing.
|
||||||
|
12. Add visual QA screenshots for:
|
||||||
|
- no document open
|
||||||
|
- PDF open in default reading mode
|
||||||
|
- highlight comment popover
|
||||||
|
- selected-text comment popover
|
||||||
|
- comments review sidebar open
|
||||||
|
- dark mode reading
|
||||||
|
13. Run a design review using real academic PDFs, lecture slides, scanned readings, and long journal articles.
|
||||||
|
|
||||||
|
### Revised UI Acceptance Standard
|
||||||
|
|
||||||
|
The app is not visually acceptable until a user can open a PDF and immediately understand:
|
||||||
|
|
||||||
|
- where the document is,
|
||||||
|
- how to highlight selected text,
|
||||||
|
- where to type the comment,
|
||||||
|
- how to close the comment and keep reading,
|
||||||
|
- how to reopen the comment,
|
||||||
|
- how to show or hide all comments,
|
||||||
|
- and how to save/share the annotated PDF.
|
||||||
|
- how comment replies belong to a parent comment in the review sidebar.
|
||||||
|
|
||||||
|
No default screen should make the user feel like they are managing a database of comments before they have started reading.
|
||||||
|
|
||||||
|
### 1. PDF Opening
|
||||||
|
|
||||||
|
The app must allow the user to open a local `.pdf` file from disk.
|
||||||
|
|
||||||
|
The app must support:
|
||||||
|
|
||||||
|
- Text-based PDFs
|
||||||
|
- Scanned/image-based PDFs
|
||||||
|
- Multi-page PDFs
|
||||||
|
- Large academic PDFs of at least 500 pages
|
||||||
|
|
||||||
|
### 2. Reading Interface
|
||||||
|
|
||||||
|
The app must provide:
|
||||||
|
|
||||||
|
- Page scrolling
|
||||||
|
- Zoom in and zoom out
|
||||||
|
- Fit to width
|
||||||
|
- Fit to page
|
||||||
|
- Page number navigation
|
||||||
|
- Search within selectable text PDFs
|
||||||
|
- Sidebar with page thumbnails
|
||||||
|
- Sidebar toggle
|
||||||
|
|
||||||
|
### 3. Annotation Types
|
||||||
|
|
||||||
|
The app must support at minimum:
|
||||||
|
|
||||||
|
- Highlight annotation with optional comment
|
||||||
|
- Selection-bound comment created from selected text, including right-click Comment
|
||||||
|
- Underline annotation with optional comment
|
||||||
|
- Free-text annotation placed directly on the page
|
||||||
|
|
||||||
|
The first version does not need standalone page-placement comments, drawing, shapes, stamps, audio annotations, collaboration, OCR, or AI features.
|
||||||
|
|
||||||
|
### 4. Comment Popups
|
||||||
|
|
||||||
|
For every annotation that has a comment, the user must be able to open a popup displaying the comment text.
|
||||||
|
|
||||||
|
The popup must support:
|
||||||
|
|
||||||
|
- Viewing the full comment
|
||||||
|
- Editing the comment
|
||||||
|
- Closing the popup
|
||||||
|
- Reopening the popup by clicking the annotation
|
||||||
|
|
||||||
|
When the PDF is saved and opened in another PDF reader, the comment must still be associated with the annotation and must be openable there.
|
||||||
|
|
||||||
|
### 5. Saving
|
||||||
|
|
||||||
|
The app must support:
|
||||||
|
|
||||||
|
- Save annotations into the original PDF
|
||||||
|
- Save As a new annotated copy
|
||||||
|
- Warn before overwriting the original file
|
||||||
|
- Preserve existing PDF content
|
||||||
|
- Preserve existing annotations from other PDF readers whenever possible
|
||||||
|
|
||||||
|
### 6. Interoperability
|
||||||
|
|
||||||
|
The app must not rely on proprietary annotation storage.
|
||||||
|
|
||||||
|
A successful export means:
|
||||||
|
|
||||||
|
- Highlighted text remains highlighted
|
||||||
|
- Selection-bound comments remain visible and readable as standard PDF annotations
|
||||||
|
- Comments remain readable as popup annotation contents
|
||||||
|
- The PDF can be emailed or uploaded to an LMS without losing comments
|
||||||
|
|
||||||
|
### 7. Annotation Sidebar
|
||||||
|
|
||||||
|
The app must include an annotation list showing:
|
||||||
|
|
||||||
|
- Page number
|
||||||
|
- Annotation type
|
||||||
|
- Author name
|
||||||
|
- Date created
|
||||||
|
- First line of comment text
|
||||||
|
|
||||||
|
Clicking an item in the list must navigate to that annotation.
|
||||||
|
|
||||||
|
### 8. Comments Review Sidebar
|
||||||
|
|
||||||
|
The app must include an Adobe Acrobat-style comments review sidebar for quickly reviewing and responding to annotations across the whole PDF.
|
||||||
|
|
||||||
|
This is separate from one-off annotation popups. It should provide a persistent document-level comments panel that can be opened beside the PDF reading area.
|
||||||
|
|
||||||
|
The comments sidebar must support:
|
||||||
|
|
||||||
|
- Total comment count for the current PDF
|
||||||
|
- Comments grouped by page
|
||||||
|
- Collapsible page groups
|
||||||
|
- Search within comments
|
||||||
|
- Filtering comments by annotation type, author, and status where practical
|
||||||
|
- Author name for each comment
|
||||||
|
- Date and time created or modified
|
||||||
|
- Full comment text, not only a truncated preview
|
||||||
|
- Reply threads attached to an existing annotation comment
|
||||||
|
- Adding a reply from the sidebar
|
||||||
|
- Editing the user's own comments from the sidebar
|
||||||
|
- Deleting the user's own comments from the sidebar
|
||||||
|
- Clicking a comment to navigate to the corresponding page and annotation
|
||||||
|
- Selecting the associated annotation in the PDF view when a comment is selected
|
||||||
|
|
||||||
|
Threaded replies should be saved using standards-compliant PDF annotation reply relationships where supported by PDFKit or the chosen PDF-writing layer. If full reply interoperability is limited by common PDF readers, the app must still preserve the primary annotation comment as standard PDF annotation contents and document the known limitations.
|
||||||
|
|
||||||
|
The comments sidebar should feel native to macOS: compact, quiet, keyboard-navigable, accessible, and suitable for long review sessions.
|
||||||
|
|
||||||
|
The app must not require AI features for comment review. Any future summary feature must be optional and out of scope for version 1 unless explicitly added later.
|
||||||
|
|
||||||
|
### 9. Professor-Focused Workflow
|
||||||
|
|
||||||
|
The app should make academic annotation fast.
|
||||||
|
|
||||||
|
Required workflow:
|
||||||
|
|
||||||
|
- Open PDF
|
||||||
|
- Select text
|
||||||
|
- Click highlight
|
||||||
|
- Type optional comment
|
||||||
|
- Continue reading
|
||||||
|
- Save annotated PDF
|
||||||
|
- Share file
|
||||||
|
|
||||||
|
The app should not require accounts, cloud sync, project setup, import libraries, or document conversion.
|
||||||
|
|
||||||
|
## Out of Scope for Version 1
|
||||||
|
|
||||||
|
The following are explicitly not required:
|
||||||
|
|
||||||
|
- Real-time collaboration
|
||||||
|
- Cloud storage
|
||||||
|
- User accounts
|
||||||
|
- LMS integration
|
||||||
|
- Citation management
|
||||||
|
- OCR
|
||||||
|
- AI summarization
|
||||||
|
- Handwriting recognition
|
||||||
|
- iPad support
|
||||||
|
- Windows/Linux support
|
||||||
|
- Browser extension
|
||||||
|
- Mobile app
|
||||||
|
- Custom proprietary comment system
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
The project is complete when:
|
||||||
|
|
||||||
|
1. A professor can open a PDF on macOS, highlight text, add a comment, save the file, and reopen it with the annotation still present.
|
||||||
|
2. A second person can open that saved PDF in macOS Preview or Adobe Acrobat Reader and see the highlight and open the comment popup.
|
||||||
|
3. Selection-bound comments created in the app remain visible as standard PDF annotations in other readers.
|
||||||
|
4. Existing PDF text, images, layout, bookmarks, and prior annotations are not destroyed during save.
|
||||||
|
5. The app can be built from source using documented commands.
|
||||||
|
6. The GitHub repository includes installation instructions, development setup instructions, license, screenshots, and a basic roadmap.
|
||||||
|
7. The app visually fits on macOS, supports light and dark mode, uses native-feeling controls and keyboard shortcuts, and remains pleasant to use during long PDF reading sessions.
|
||||||
|
8. The app includes a persistent comments review sidebar that shows the document comment count, groups comments by page, supports search/filtering, supports replies where interoperable, highlights referenced text on hover, and navigates from a sidebar comment to the matching PDF annotation.
|
||||||
|
9. The app passes a design review against Apple's macOS Human Interface Guidelines before the first public release.
|
||||||
|
|
||||||
|
## One-Sentence Version
|
||||||
|
|
||||||
|
Build an open-source, polished, native-feeling macOS PDF reader and annotation app for professors that saves highlights, underlines, and selection-bound comments as standard embedded PDF annotations so annotated PDFs can be shared and viewed with pop-up comments in common PDF readers without requiring the app.
|
||||||
BIN
ihatepdf.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
117
scripts/build-app.sh
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_NAME="I Hate PDFs"
|
||||||
|
EXECUTABLE_NAME="IHatePDFs"
|
||||||
|
CONFIGURATION="${CONFIGURATION:-release}"
|
||||||
|
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"
|
||||||
|
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||||
|
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||||
|
ICON_SOURCE="$ROOT_DIR/ihatepdf.png"
|
||||||
|
ICON_NAME="AppIcon"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
SWIFT_BUILD_ARGS=(-c "$CONFIGURATION")
|
||||||
|
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)"
|
||||||
|
|
||||||
|
rm -rf "$APP_DIR"
|
||||||
|
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||||
|
cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME"
|
||||||
|
|
||||||
|
if [[ ! -f "$ICON_SOURCE" ]]; then
|
||||||
|
echo "Missing app icon source: $ICON_SOURCE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ICONSET_DIR="$DIST_DIR/$ICON_NAME.iconset"
|
||||||
|
rm -rf "$ICONSET_DIR"
|
||||||
|
mkdir -p "$ICONSET_DIR"
|
||||||
|
|
||||||
|
make_icon() {
|
||||||
|
local pixels="$1"
|
||||||
|
local output="$2"
|
||||||
|
sips -s format png --resampleHeightWidth "$pixels" "$pixels" "$ICON_SOURCE" --out "$ICONSET_DIR/$output" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
make_icon 16 "icon_16x16.png"
|
||||||
|
make_icon 32 "icon_16x16@2x.png"
|
||||||
|
make_icon 32 "icon_32x32.png"
|
||||||
|
make_icon 64 "icon_32x32@2x.png"
|
||||||
|
make_icon 128 "icon_128x128.png"
|
||||||
|
make_icon 256 "icon_128x128@2x.png"
|
||||||
|
make_icon 256 "icon_256x256.png"
|
||||||
|
make_icon 512 "icon_256x256@2x.png"
|
||||||
|
make_icon 512 "icon_512x512.png"
|
||||||
|
make_icon 1024 "icon_512x512@2x.png"
|
||||||
|
|
||||||
|
iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/$ICON_NAME.icns"
|
||||||
|
rm -rf "$ICONSET_DIR"
|
||||||
|
|
||||||
|
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$EXECUTABLE_NAME</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>org.ihatepdfs.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$APP_NAME</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>$APP_NAME</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>$ICON_NAME</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>PDF Document</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>LSHandlerRank</key>
|
||||||
|
<string>Alternate</string>
|
||||||
|
<key>LSItemContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>com.adobe.pdf</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>MIT License</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
echo "Built $APP_DIR"
|
||||||
22
scripts/make-dmg.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
APP_NAME="I Hate PDFs"
|
||||||
|
DIST_DIR="$ROOT_DIR/dist"
|
||||||
|
APP_DIR="$DIST_DIR/$APP_NAME.app"
|
||||||
|
DMG_PATH="$DIST_DIR/IHatePDFs.dmg"
|
||||||
|
|
||||||
|
if [[ ! -d "$APP_DIR" ]]; then
|
||||||
|
"$ROOT_DIR/scripts/build-app.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$DMG_PATH"
|
||||||
|
hdiutil create \
|
||||||
|
-volname "$APP_NAME" \
|
||||||
|
-srcfolder "$APP_DIR" \
|
||||||
|
-ov \
|
||||||
|
-format UDZO \
|
||||||
|
"$DMG_PATH"
|
||||||
|
|
||||||
|
echo "Created $DMG_PATH"
|
||||||
283
scripts/verify-pdf-annotations.swift
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AnnotationSummary {
|
||||||
|
var highlights = 0
|
||||||
|
var selectedTextComments = 0
|
||||||
|
var underlines = 0
|
||||||
|
var textNotes = 0
|
||||||
|
var replies = 0
|
||||||
|
var freeText = 0
|
||||||
|
var popups = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerificationError: Error, CustomStringConvertible {
|
||||||
|
case unreadablePDF(String)
|
||||||
|
case missingPageAnnotations(Int)
|
||||||
|
case missingDictionary(page: Int, index: Int)
|
||||||
|
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 missingExpectedSubtype(String)
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .unreadablePDF(let path):
|
||||||
|
return "Unable to read PDF at \(path)"
|
||||||
|
case .missingPageAnnotations(let page):
|
||||||
|
return "Page \(page) has no /Annots array"
|
||||||
|
case .missingDictionary(let page, let index):
|
||||||
|
return "Annotation \(index) on page \(page) is not a dictionary"
|
||||||
|
case .missingName(let page, let index, let key):
|
||||||
|
return "Annotation \(index) on page \(page) is missing name key /\(key)"
|
||||||
|
case .missingString(let page, let index, let key):
|
||||||
|
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 .missingExpectedSubtype(let subtype):
|
||||||
|
return "Expected at least one /\(subtype) annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputPath = CommandLine.arguments.dropFirst().first ?? "dist/annotation-verification.pdf"
|
||||||
|
let inputURL = URL(fileURLWithPath: inputPath)
|
||||||
|
|
||||||
|
guard let document = CGPDFDocument(inputURL as CFURL) else {
|
||||||
|
throw VerificationError.unreadablePDF(inputURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = AnnotationSummary()
|
||||||
|
|
||||||
|
for pageNumber in 1...document.numberOfPages {
|
||||||
|
guard let page = document.page(at: pageNumber),
|
||||||
|
let pageDictionary = page.dictionary
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var annotationsArray: CGPDFArrayRef?
|
||||||
|
guard CGPDFDictionaryGetArray(pageDictionary, "Annots", &annotationsArray),
|
||||||
|
let annotationsArray
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for annotationIndex in 0..<CGPDFArrayGetCount(annotationsArray) {
|
||||||
|
let annotation = try annotationDictionary(
|
||||||
|
in: annotationsArray,
|
||||||
|
at: annotationIndex,
|
||||||
|
page: pageNumber
|
||||||
|
)
|
||||||
|
let subtype = try nameValue(
|
||||||
|
in: annotation,
|
||||||
|
key: "Subtype",
|
||||||
|
page: pageNumber,
|
||||||
|
index: annotationIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
switch subtype {
|
||||||
|
case "Highlight":
|
||||||
|
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
||||||
|
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Highlight")
|
||||||
|
if hasString(in: annotation, key: "IHatePDFsKind") {
|
||||||
|
try requireString(in: annotation, key: "IHatePDFsKind", page: pageNumber, index: annotationIndex)
|
||||||
|
summary.selectedTextComments += 1
|
||||||
|
} else {
|
||||||
|
summary.highlights += 1
|
||||||
|
}
|
||||||
|
case "Underline":
|
||||||
|
summary.underlines += 1
|
||||||
|
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
||||||
|
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Underline")
|
||||||
|
case "Text":
|
||||||
|
try requireTextKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
||||||
|
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Text")
|
||||||
|
|
||||||
|
if hasString(in: annotation, key: "IRT") || hasString(in: annotation, key: "RT") {
|
||||||
|
summary.replies += 1
|
||||||
|
try requireString(in: annotation, key: "IRT", page: pageNumber, index: annotationIndex)
|
||||||
|
try requireString(in: annotation, key: "RT", page: pageNumber, index: annotationIndex)
|
||||||
|
} else {
|
||||||
|
summary.textNotes += 1
|
||||||
|
}
|
||||||
|
case "FreeText":
|
||||||
|
summary.freeText += 1
|
||||||
|
try requireString(in: annotation, key: "Contents", page: pageNumber, index: annotationIndex)
|
||||||
|
try requireString(in: annotation, key: "T", page: pageNumber, index: annotationIndex)
|
||||||
|
try requireString(in: annotation, key: "M", page: pageNumber, index: annotationIndex)
|
||||||
|
try requireString(in: annotation, key: "DA", page: pageNumber, index: annotationIndex)
|
||||||
|
try requireArray(in: annotation, key: "C", page: pageNumber, index: annotationIndex)
|
||||||
|
try requireArray(in: annotation, key: "Rect", page: pageNumber, index: annotationIndex)
|
||||||
|
case "Popup":
|
||||||
|
summary.popups += 1
|
||||||
|
var parentDictionary: CGPDFDictionaryRef?
|
||||||
|
guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary) else {
|
||||||
|
throw VerificationError.missingPopupParent(page: pageNumber, index: annotationIndex)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard summary.highlights > 0 else {
|
||||||
|
throw VerificationError.missingExpectedSubtype("Highlight")
|
||||||
|
}
|
||||||
|
guard summary.selectedTextComments > 0 else {
|
||||||
|
throw VerificationError.missingExpectedSubtype("selected-text Comment")
|
||||||
|
}
|
||||||
|
guard summary.underlines > 0 else {
|
||||||
|
throw VerificationError.missingExpectedSubtype("Underline")
|
||||||
|
}
|
||||||
|
guard summary.textNotes > 0 else {
|
||||||
|
throw VerificationError.missingExpectedSubtype("Text")
|
||||||
|
}
|
||||||
|
guard summary.replies > 0 else {
|
||||||
|
throw VerificationError.missingExpectedSubtype("Text reply")
|
||||||
|
}
|
||||||
|
guard summary.freeText > 0 else {
|
||||||
|
throw VerificationError.missingExpectedSubtype("FreeText")
|
||||||
|
}
|
||||||
|
guard summary.popups >= 5 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(
|
||||||
|
in array: CGPDFArrayRef,
|
||||||
|
at index: Int,
|
||||||
|
page: Int
|
||||||
|
) throws -> CGPDFDictionaryRef {
|
||||||
|
var object: CGPDFObjectRef?
|
||||||
|
guard CGPDFArrayGetObject(array, index, &object),
|
||||||
|
let object
|
||||||
|
else {
|
||||||
|
throw VerificationError.missingDictionary(page: page, index: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dictionary: CGPDFDictionaryRef?
|
||||||
|
guard CGPDFObjectGetValue(object, .dictionary, &dictionary),
|
||||||
|
let dictionary
|
||||||
|
else {
|
||||||
|
throw VerificationError.missingDictionary(page: page, index: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dictionary
|
||||||
|
}
|
||||||
|
|
||||||
|
func nameValue(
|
||||||
|
in dictionary: CGPDFDictionaryRef,
|
||||||
|
key: String,
|
||||||
|
page: Int,
|
||||||
|
index: Int
|
||||||
|
) throws -> String {
|
||||||
|
var name: UnsafePointer<Int8>?
|
||||||
|
guard CGPDFDictionaryGetName(dictionary, key, &name),
|
||||||
|
let name
|
||||||
|
else {
|
||||||
|
throw VerificationError.missingName(page: page, index: index, key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(cString: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireString(
|
||||||
|
in dictionary: CGPDFDictionaryRef,
|
||||||
|
key: String,
|
||||||
|
page: Int,
|
||||||
|
index: Int
|
||||||
|
) throws {
|
||||||
|
var value: CGPDFStringRef?
|
||||||
|
guard CGPDFDictionaryGetString(dictionary, key, &value),
|
||||||
|
value != nil
|
||||||
|
else {
|
||||||
|
throw VerificationError.missingString(page: page, index: index, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireName(
|
||||||
|
in dictionary: CGPDFDictionaryRef,
|
||||||
|
key: String,
|
||||||
|
page: Int,
|
||||||
|
index: Int
|
||||||
|
) throws {
|
||||||
|
var value: UnsafePointer<Int8>?
|
||||||
|
guard CGPDFDictionaryGetName(dictionary, key, &value),
|
||||||
|
value != nil
|
||||||
|
else {
|
||||||
|
throw VerificationError.missingName(page: page, index: index, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasString(
|
||||||
|
in dictionary: CGPDFDictionaryRef,
|
||||||
|
key: String
|
||||||
|
) -> Bool {
|
||||||
|
var value: CGPDFStringRef?
|
||||||
|
return CGPDFDictionaryGetString(dictionary, key, &value) && value != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireArray(
|
||||||
|
in dictionary: CGPDFDictionaryRef,
|
||||||
|
key: String,
|
||||||
|
page: Int,
|
||||||
|
index: Int
|
||||||
|
) throws {
|
||||||
|
var value: CGPDFArrayRef?
|
||||||
|
guard CGPDFDictionaryGetArray(dictionary, key, &value),
|
||||||
|
value != nil
|
||||||
|
else {
|
||||||
|
throw VerificationError.missingArray(page: page, index: index, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireMarkupKeys(
|
||||||
|
in dictionary: CGPDFDictionaryRef,
|
||||||
|
page: Int,
|
||||||
|
index: Int
|
||||||
|
) throws {
|
||||||
|
try requireString(in: dictionary, key: "Contents", page: page, index: index)
|
||||||
|
try requireArray(in: dictionary, key: "QuadPoints", page: page, index: index)
|
||||||
|
try requireArray(in: dictionary, key: "C", page: page, index: index)
|
||||||
|
try requireString(in: dictionary, key: "T", page: page, index: index)
|
||||||
|
try requireString(in: dictionary, key: "M", page: page, index: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireTextKeys(
|
||||||
|
in dictionary: CGPDFDictionaryRef,
|
||||||
|
page: Int,
|
||||||
|
index: Int
|
||||||
|
) throws {
|
||||||
|
try requireString(in: dictionary, key: "Contents", page: page, index: index)
|
||||||
|
try requireName(in: dictionary, key: "Name", page: page, index: index)
|
||||||
|
try requireArray(in: dictionary, key: "C", page: page, index: index)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
180
scripts/verify-sample-pdf.swift
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import PDFKit
|
||||||
|
|
||||||
|
let outputURL = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "dist/annotation-verification.pdf")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outputURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let page = PDFPage()
|
||||||
|
let document = PDFDocument()
|
||||||
|
document.insert(page, at: 0)
|
||||||
|
|
||||||
|
let verificationDate = Date(timeIntervalSince1970: 1_797_000_000)
|
||||||
|
|
||||||
|
let highlight = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 72, y: 620, width: 260, height: 24),
|
||||||
|
forType: .highlight,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
highlight.markupType = .highlight
|
||||||
|
highlight.color = NSColor(calibratedRed: 0.88, green: 0.72, blue: 0.46, alpha: 0.24)
|
||||||
|
highlight.quadrilateralPoints = quadPoints(width: 260, height: 24)
|
||||||
|
standardize(
|
||||||
|
highlight,
|
||||||
|
name: "verify-highlight",
|
||||||
|
contents: "This is a standards-compliant PDF highlight comment.",
|
||||||
|
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),
|
||||||
|
forType: .highlight,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
selectedTextComment.markupType = .highlight
|
||||||
|
selectedTextComment.color = NSColor(calibratedRed: 0.88, green: 0.72, blue: 0.46, alpha: 0.10)
|
||||||
|
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.",
|
||||||
|
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),
|
||||||
|
forType: .underline,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
underline.markupType = .underline
|
||||||
|
underline.color = NSColor(calibratedRed: 0.48, green: 0.53, blue: 0.62, alpha: 0.56)
|
||||||
|
underline.quadrilateralPoints = quadPoints(width: 260, height: 24)
|
||||||
|
standardize(
|
||||||
|
underline,
|
||||||
|
name: "verify-underline",
|
||||||
|
contents: "This underline comment should remain openable.",
|
||||||
|
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),
|
||||||
|
forType: .text,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
textNote.iconType = .note
|
||||||
|
textNote.color = NSColor(calibratedRed: 0.64, green: 0.59, blue: 0.49, alpha: 0.90)
|
||||||
|
standardize(
|
||||||
|
textNote,
|
||||||
|
name: "verify-text-note",
|
||||||
|
contents: "This standard PDF text annotation remains visible in common PDF readers.",
|
||||||
|
author: "Professor"
|
||||||
|
)
|
||||||
|
page.addAnnotation(textNote)
|
||||||
|
|
||||||
|
let reply = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 402, y: 586, width: 24, height: 24),
|
||||||
|
forType: .text,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
reply.iconType = .comment
|
||||||
|
reply.color = NSColor(calibratedRed: 0.52, green: 0.58, blue: 0.60, alpha: 0.88)
|
||||||
|
standardize(
|
||||||
|
reply,
|
||||||
|
name: "verify-reply",
|
||||||
|
contents: "This reply is saved as a visible PDF text annotation.",
|
||||||
|
author: "Reader"
|
||||||
|
)
|
||||||
|
_ = reply.setValue("verify-text-note", forAnnotationKey: PDFAnnotationKey(rawValue: "IRT"))
|
||||||
|
_ = reply.setValue("R", forAnnotationKey: PDFAnnotationKey(rawValue: "RT"))
|
||||||
|
page.addAnnotation(reply)
|
||||||
|
|
||||||
|
let freeText = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 72, y: 500, width: 260, height: 50),
|
||||||
|
forType: .freeText,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
freeText.font = NSFont.systemFont(ofSize: 13)
|
||||||
|
freeText.fontColor = NSColor(calibratedWhite: 0.22, alpha: 1)
|
||||||
|
freeText.color = NSColor(calibratedRed: 0.91, green: 0.86, blue: 0.75, alpha: 0.32)
|
||||||
|
freeText.alignment = .left
|
||||||
|
let border = PDFBorder()
|
||||||
|
border.lineWidth = 0.75
|
||||||
|
freeText.border = border
|
||||||
|
standardize(
|
||||||
|
freeText,
|
||||||
|
name: "verify-free-text",
|
||||||
|
contents: "Free text remains visible on the PDF page.",
|
||||||
|
author: "Professor"
|
||||||
|
)
|
||||||
|
page.addAnnotation(freeText)
|
||||||
|
|
||||||
|
guard document.write(to: outputURL) else {
|
||||||
|
fatalError("Unable to write \(outputURL.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let reopened = PDFDocument(url: outputURL)!
|
||||||
|
let annotations = reopened.page(at: 0)!.annotations
|
||||||
|
precondition(annotations.contains { matches($0, .highlight) && $0.contents?.contains("highlight") == true })
|
||||||
|
precondition(annotations.contains { matches($0, .highlight) && $0.contents?.contains("selected-text comment") == true })
|
||||||
|
precondition(annotations.contains { matches($0, .underline) && $0.contents?.contains("underline") == true })
|
||||||
|
precondition(annotations.contains { matches($0, .text) && $0.contents?.contains("text annotation") == true })
|
||||||
|
precondition(annotations.contains { matches($0, .text) && $0.contents?.contains("reply") == true })
|
||||||
|
precondition(annotations.contains { matches($0, .freeText) && $0.contents?.contains("Free text") == true })
|
||||||
|
|
||||||
|
print("Verified standard PDF annotations in \(outputURL.path)")
|
||||||
|
|
||||||
|
func standardize(
|
||||||
|
_ annotation: PDFAnnotation,
|
||||||
|
name: String,
|
||||||
|
contents: String,
|
||||||
|
author: String
|
||||||
|
) {
|
||||||
|
annotation.contents = contents
|
||||||
|
annotation.userName = author
|
||||||
|
annotation.modificationDate = verificationDate
|
||||||
|
annotation.shouldDisplay = true
|
||||||
|
annotation.shouldPrint = true
|
||||||
|
_ = annotation.setValue(name, forAnnotationKey: .name)
|
||||||
|
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
|
||||||
|
_ = annotation.setValue(verificationDate, forAnnotationKey: .date)
|
||||||
|
_ = annotation.setValue("D:20261215132000Z00'00'", forAnnotationKey: PDFAnnotationKey(rawValue: "CreationDate"))
|
||||||
|
_ = 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)),
|
||||||
|
NSValue(point: CGPoint(x: width, y: height)),
|
||||||
|
NSValue(point: CGPoint(x: 0, y: 0)),
|
||||||
|
NSValue(point: CGPoint(x: width, y: 0))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(_ annotation: PDFAnnotation, _ subtype: PDFAnnotationSubtype) -> Bool {
|
||||||
|
guard let type = annotation.type else { return false }
|
||||||
|
let raw = subtype.rawValue
|
||||||
|
let normalized = raw.hasPrefix("/") ? String(raw.dropFirst()) : raw
|
||||||
|
return type == raw || type == normalized
|
||||||
|
}
|
||||||