v0.1 Comments and basic functionality work
This commit is contained in:
982
Sources/IHatePDFs/AppState.swift
Normal file
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
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
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
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
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
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
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
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
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
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user