Fix v0.4 reader workflow issues
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -17,6 +17,18 @@
|
||||
- Documented `https://www.akkolli.net/ihatepdfs` as the project website and `akshaykolli@hotmail.com` as the support contact.
|
||||
- Clarified that the project is vibe coded and maintained with AI agents, and that feature requests and agent-assisted PRs are welcome under the QA guidelines.
|
||||
|
||||
## Version 0.4.0 (build 7) - 2026-06-30
|
||||
|
||||
Build 7 keeps the public v0.4 app version and prepares another upload with targeted reader workflow fixes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added an always-visible Reply button to comment and reply rows in the comments sidebar.
|
||||
- Made Escape leave active annotation modes, including highlighter mode and free-text placement.
|
||||
- Kept the macOS full-screen shortcut available after focus moves to window chrome.
|
||||
- Added annotation undo/redo for add and delete operations.
|
||||
- Added selected-annotation deletion and visible delete controls for plain highlights.
|
||||
|
||||
## Version 0.4.0 (build 6) - 2026-06-25
|
||||
|
||||
Version 0.4 removes the experimental Fill & Sign, form-field navigation, and PDF signing implementation from the shipping app. The release is back to a small native reader and annotation tool while preserving the size target.
|
||||
|
||||
@@ -40,7 +40,7 @@ Supported Mac architectures: Apple Silicon and Intel.
|
||||
|
||||
## Latest Release
|
||||
|
||||
Current version: `0.4.0` build `6`.
|
||||
Current version: `0.4.0` build `7`.
|
||||
|
||||
Download the v0.4 macOS DMG from the GitHub release page:
|
||||
|
||||
@@ -52,7 +52,7 @@ The direct-download DMG is separate from the Mac App Store build. The App Store
|
||||
|
||||
## Features
|
||||
|
||||
- Incredibly small app size: v0.4 ships as a 957 KB direct-download DMG, with a 253 KB Apple Silicon archive and a 287 KB Intel archive.
|
||||
- Incredibly small app size: v0.4 ships as a 998 KB direct-download DMG, with a 255 KB Apple Silicon archive and a 288 KB Intel archive.
|
||||
- Extremely fast native PDF reading: built on SwiftUI, AppKit, and PDFKit instead of a bundled browser, runtime, database, or PDF engine.
|
||||
- Power- and size-efficient by design: minimal assets, no bundled services, and no background sync workload.
|
||||
- Local-first privacy: opens user-selected PDFs from disk and does not require accounts, analytics, tracking, or cloud upload.
|
||||
|
||||
20
docs/QA.md
20
docs/QA.md
@@ -16,21 +16,17 @@ Use this section as the source of truth when checking whether a feature matches
|
||||
|
||||
## Latest v0.4 Automated QA Run
|
||||
|
||||
Completed on 2026-06-29:
|
||||
Completed on 2026-06-30 for build 7:
|
||||
|
||||
- `swift build`
|
||||
- `swift test`
|
||||
- `swift scripts/verify-pdf-annotations.swift`
|
||||
- `swift build -c release --product IHatePDFs`
|
||||
- `scripts/build-app.sh`
|
||||
- `BUILD_APP=0 scripts/make-dmg.sh`
|
||||
- `scripts/make-tiny-archives.sh`
|
||||
- `PKG_PATH=dist/nonexistent-appstore.pkg scripts/verify-release-artifacts.sh`
|
||||
|
||||
Before release, also run:
|
||||
|
||||
```sh
|
||||
swift scripts/verify-pdf-annotations.swift
|
||||
swift build -c release --product IHatePDFs
|
||||
scripts/build-app.sh
|
||||
scripts/make-dmg.sh
|
||||
scripts/make-tiny-archives.sh
|
||||
scripts/verify-release-artifacts.sh
|
||||
```
|
||||
Before App Store upload, also run `scripts/make-app-store-pkg.sh` with the required signing identities and provisioning profile, then verify the generated package with `scripts/verify-release-artifacts.sh`.
|
||||
|
||||
## Test Files
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
APP_VERSION="${APP_VERSION:-0.4.0}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-6}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-7}"
|
||||
RELEASE_VERSION="${RELEASE_VERSION:-${APP_VERSION%.0}}"
|
||||
|
||||
@@ -60,6 +60,13 @@ enum AnnotationPlacementTool: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnnotationUndoRecord {
|
||||
let annotation: PDFAnnotation
|
||||
let page: PDFPage
|
||||
let index: Int?
|
||||
let popups: [PDFAnnotation]
|
||||
}
|
||||
|
||||
private enum AppDefaults {
|
||||
static let documentPageProgress = "IHatePDFs.documentPageProgress.v1"
|
||||
static let documentBookmarks = "IHatePDFs.documentBookmarks.v1"
|
||||
@@ -330,10 +337,28 @@ final class AppState: NSObject, ObservableObject {
|
||||
&& !isHighlighterModeActive
|
||||
}
|
||||
|
||||
var canCancelActiveMode: Bool {
|
||||
placementTool != nil || isHighlighterModeActive
|
||||
}
|
||||
|
||||
var canClearSearchQuery: Bool {
|
||||
!searchText.isEmpty || !searchResults.isEmpty
|
||||
}
|
||||
|
||||
var canDeleteSelectedAnnotation: Bool {
|
||||
activeEditor == nil
|
||||
&& selectedAnnotationID != nil
|
||||
&& annotations.contains { $0.id == selectedAnnotationID }
|
||||
}
|
||||
|
||||
var canUndoAnnotationChange: Bool {
|
||||
annotationUndoManager?.canUndo == true
|
||||
}
|
||||
|
||||
var canRedoAnnotationChange: Bool {
|
||||
annotationUndoManager?.canRedo == true
|
||||
}
|
||||
|
||||
var searchSummaryText: String? {
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return nil }
|
||||
@@ -378,6 +403,10 @@ final class AppState: NSObject, ObservableObject {
|
||||
return [ReviewState.allStatuses] + preferred + custom
|
||||
}
|
||||
|
||||
private var annotationUndoManager: UndoManager? {
|
||||
pdfView?.undoManager ?? hostingWindow?.undoManager
|
||||
}
|
||||
|
||||
var currentPageBookmark: PDFDocumentBookmark? {
|
||||
PDFDocumentBookmarks.bookmark(on: currentPageIndex, in: bookmarks)
|
||||
}
|
||||
@@ -1065,6 +1094,25 @@ final class AppState: NSObject, ObservableObject {
|
||||
statusMessage = placementTool.cancellationMessage
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func cancelActiveMode() -> Bool {
|
||||
var messages: [String] = []
|
||||
|
||||
if let placementTool {
|
||||
self.placementTool = nil
|
||||
messages.append(placementTool.cancellationMessage)
|
||||
}
|
||||
|
||||
if isHighlighterModeActive {
|
||||
isHighlighterModeActive = false
|
||||
messages.append("Highlighter off.")
|
||||
}
|
||||
|
||||
guard !messages.isEmpty else { return false }
|
||||
statusMessage = messages.joined(separator: " ")
|
||||
return true
|
||||
}
|
||||
|
||||
func placePendingAnnotation(on page: PDFPage, near point: CGPoint) {
|
||||
guard let placementTool else { return }
|
||||
|
||||
@@ -1082,7 +1130,8 @@ final class AppState: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
self.placementTool = nil
|
||||
add(insertion)
|
||||
let record = add(insertion)
|
||||
registerUndoToRemoveAnnotations([record], actionName: "Add Free Text")
|
||||
refreshAnnotations(on: [page])
|
||||
openEditor(
|
||||
title: "Free Text",
|
||||
@@ -1157,7 +1206,8 @@ final class AppState: NSObject, ObservableObject {
|
||||
author: author.isEmpty ? AnnotationFactory.defaultAuthor : author,
|
||||
parentID: parent.id
|
||||
)
|
||||
add(insertion)
|
||||
let record = add(insertion)
|
||||
registerUndoToRemoveAnnotations([record], actionName: "Add Reply")
|
||||
clearSidebarReplyDraft()
|
||||
refreshAnnotations(on: [parent.page])
|
||||
|
||||
@@ -1205,17 +1255,20 @@ final class AppState: NSObject, ObservableObject {
|
||||
}
|
||||
let targetIDs = Set(targets.map(\.id))
|
||||
let targetPages = targets.map(\.page)
|
||||
let actionName = deleteActionName(for: item, targetCount: targets.count)
|
||||
|
||||
guard confirmDiscardSidebarReplyDraftIfNeeded(
|
||||
deleting: targetIDs,
|
||||
actionName: targets.count > 1 ? "deleting this comment thread" : "deleting this comment"
|
||||
actionName: deletingActionPhrase(for: item, targetCount: targets.count)
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let records = annotationUndoRecords(for: targets)
|
||||
for target in targets {
|
||||
removeAnnotation(target.annotation, from: target.page)
|
||||
}
|
||||
registerUndoToRestoreAnnotations(records, actionName: actionName)
|
||||
|
||||
if selectedAnnotationID.map(targetIDs.contains) == true {
|
||||
selectedAnnotationID = nil
|
||||
@@ -1227,7 +1280,27 @@ final class AppState: NSObject, ObservableObject {
|
||||
|
||||
activeEditor = nil
|
||||
refreshAnnotations(on: targetPages)
|
||||
statusMessage = targets.count > 1 ? "Comment thread deleted." : "Comment deleted."
|
||||
statusMessage = deleteStatusMessage(for: item, targetCount: targets.count)
|
||||
}
|
||||
|
||||
func deleteSelectedAnnotation() {
|
||||
guard activeEditor == nil else { return }
|
||||
guard let selectedAnnotationID,
|
||||
let item = annotations.first(where: { $0.id == selectedAnnotationID })
|
||||
else {
|
||||
statusMessage = "Select an annotation before deleting."
|
||||
return
|
||||
}
|
||||
|
||||
delete(item)
|
||||
}
|
||||
|
||||
func undoAnnotationChange() {
|
||||
annotationUndoManager?.undo()
|
||||
}
|
||||
|
||||
func redoAnnotationChange() {
|
||||
annotationUndoManager?.redo()
|
||||
}
|
||||
|
||||
func toggleReviewed(_ item: AnnotationSnapshot) {
|
||||
@@ -1295,14 +1368,24 @@ final class AppState: NSObject, ObservableObject {
|
||||
}
|
||||
let targetIDs = Set(targets.map(\.id))
|
||||
let targetPages = targets.map(\.page)
|
||||
let representative = targets.first ?? contextSnapshots.first
|
||||
let actionName = representative.map {
|
||||
deleteActionName(for: $0, targetCount: targets.isEmpty ? context.annotations.count : targets.count)
|
||||
} ?? "Delete Annotation"
|
||||
|
||||
guard confirmDiscardSidebarReplyDraftIfNeeded(
|
||||
deleting: targetIDs,
|
||||
actionName: targets.count > 1 ? "deleting this comment thread" : "deleting this annotation"
|
||||
actionName: representative.map {
|
||||
deletingActionPhrase(for: $0, targetCount: targets.isEmpty ? context.annotations.count : targets.count)
|
||||
} ?? "deleting this annotation"
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let records = targets.isEmpty
|
||||
? annotationUndoRecords(for: context.annotations, pages: context.pages)
|
||||
: annotationUndoRecords(for: targets)
|
||||
|
||||
if targets.isEmpty {
|
||||
for (index, annotation) in context.annotations.enumerated() {
|
||||
guard index < context.pages.count else { continue }
|
||||
@@ -1313,6 +1396,7 @@ final class AppState: NSObject, ObservableObject {
|
||||
removeAnnotation(target.annotation, from: target.page)
|
||||
}
|
||||
}
|
||||
registerUndoToRestoreAnnotations(records, actionName: actionName)
|
||||
|
||||
activeEditor = nil
|
||||
if targetIDs.isEmpty || selectedAnnotationID.map(targetIDs.contains) == true {
|
||||
@@ -1326,7 +1410,9 @@ final class AppState: NSObject, ObservableObject {
|
||||
if context.isNewAnnotation {
|
||||
hasUnsavedChanges = context.hadUnsavedChangesBeforeCreation
|
||||
}
|
||||
statusMessage = targets.count > 1 ? "Comment thread deleted." : "Annotation deleted."
|
||||
statusMessage = representative.map {
|
||||
deleteStatusMessage(for: $0, targetCount: targets.isEmpty ? context.annotations.count : targets.count)
|
||||
} ?? "Annotation deleted."
|
||||
}
|
||||
|
||||
func select(_ item: AnnotationSnapshot) {
|
||||
@@ -1626,9 +1712,11 @@ final class AppState: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
let hadUnsavedChangesBeforeCreation = hasUnsavedChanges
|
||||
var records: [AnnotationUndoRecord] = []
|
||||
for insertion in insertions {
|
||||
add(insertion)
|
||||
records.append(add(insertion))
|
||||
}
|
||||
registerUndoToRemoveAnnotations(records, actionName: addActionName(for: style))
|
||||
pdfView?.clearSelection()
|
||||
updateTextSelectionState()
|
||||
refreshAnnotations(on: insertions.map(\.page))
|
||||
@@ -1654,7 +1742,8 @@ final class AppState: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func add(_ insertion: AnnotationInsertion) {
|
||||
@discardableResult
|
||||
private func add(_ insertion: AnnotationInsertion) -> AnnotationUndoRecord {
|
||||
insertion.page.addAnnotation(insertion.annotation)
|
||||
if AnnotationKeys.isReply(insertion.annotation) {
|
||||
AnnotationFactory.hideReplyMarker(insertion.annotation, on: insertion.page)
|
||||
@@ -1662,6 +1751,169 @@ final class AppState: NSObject, ObservableObject {
|
||||
detachPopupMarkerFromViewer(for: insertion.annotation, on: insertion.page)
|
||||
hasUnsavedChanges = true
|
||||
pdfView?.annotationsChanged(on: insertion.page)
|
||||
return annotationUndoRecord(for: insertion.annotation, on: insertion.page)
|
||||
}
|
||||
|
||||
private func registerUndoToRemoveAnnotations(
|
||||
_ records: [AnnotationUndoRecord],
|
||||
actionName: String
|
||||
) {
|
||||
guard !records.isEmpty, let undoManager = annotationUndoManager else { return }
|
||||
|
||||
undoManager.registerUndo(withTarget: self) { target in
|
||||
target.removeAnnotationsForUndo(records, actionName: actionName)
|
||||
}
|
||||
undoManager.setActionName(actionName)
|
||||
}
|
||||
|
||||
private func registerUndoToRestoreAnnotations(
|
||||
_ records: [AnnotationUndoRecord],
|
||||
actionName: String
|
||||
) {
|
||||
guard !records.isEmpty, let undoManager = annotationUndoManager else { return }
|
||||
|
||||
undoManager.registerUndo(withTarget: self) { target in
|
||||
target.restoreAnnotationsForUndo(records, actionName: actionName)
|
||||
}
|
||||
undoManager.setActionName(actionName)
|
||||
}
|
||||
|
||||
private func removeAnnotationsForUndo(
|
||||
_ records: [AnnotationUndoRecord],
|
||||
actionName: String
|
||||
) {
|
||||
registerUndoToRestoreAnnotations(records, actionName: actionName)
|
||||
clearStateForRemovedAnnotations(records.map(\.annotation))
|
||||
for record in records {
|
||||
removeAnnotation(record.annotation, from: record.page)
|
||||
}
|
||||
activeEditor = nil
|
||||
refreshAnnotations(on: records.map(\.page))
|
||||
statusMessage = "Annotation change undone."
|
||||
}
|
||||
|
||||
private func restoreAnnotationsForUndo(
|
||||
_ records: [AnnotationUndoRecord],
|
||||
actionName: String
|
||||
) {
|
||||
registerUndoToRemoveAnnotations(records, actionName: actionName)
|
||||
for record in sortedUndoRecordsForRestore(records) {
|
||||
restoreAnnotation(record)
|
||||
}
|
||||
activeEditor = nil
|
||||
refreshAnnotations(on: records.map(\.page))
|
||||
statusMessage = "Annotation change restored."
|
||||
}
|
||||
|
||||
private func annotationUndoRecords(for targets: [AnnotationSnapshot]) -> [AnnotationUndoRecord] {
|
||||
uniqueUndoRecords(targets.map { annotationUndoRecord(for: $0.annotation, on: $0.page) })
|
||||
}
|
||||
|
||||
private func annotationUndoRecords(
|
||||
for annotations: [PDFAnnotation],
|
||||
pages: [PDFPage]
|
||||
) -> [AnnotationUndoRecord] {
|
||||
let records = annotations.enumerated().compactMap { index, annotation -> AnnotationUndoRecord? in
|
||||
guard index < pages.count else { return nil }
|
||||
return annotationUndoRecord(for: annotation, on: pages[index])
|
||||
}
|
||||
return uniqueUndoRecords(records)
|
||||
}
|
||||
|
||||
private func annotationUndoRecord(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage
|
||||
) -> AnnotationUndoRecord {
|
||||
AnnotationUndoRecord(
|
||||
annotation: annotation,
|
||||
page: page,
|
||||
index: page.annotations.firstIndex { $0 === annotation },
|
||||
popups: linkedPopups(for: annotation, on: page)
|
||||
)
|
||||
}
|
||||
|
||||
private func uniqueUndoRecords(_ records: [AnnotationUndoRecord]) -> [AnnotationUndoRecord] {
|
||||
var seen = Set<ObjectIdentifier>()
|
||||
var result: [AnnotationUndoRecord] = []
|
||||
for record in records {
|
||||
let id = ObjectIdentifier(record.annotation)
|
||||
guard seen.insert(id).inserted else { continue }
|
||||
result.append(record)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func sortedUndoRecordsForRestore(_ records: [AnnotationUndoRecord]) -> [AnnotationUndoRecord] {
|
||||
records.sorted { left, right in
|
||||
switch (left.index, right.index) {
|
||||
case let (leftIndex?, rightIndex?):
|
||||
return leftIndex < rightIndex
|
||||
case (_?, nil):
|
||||
return true
|
||||
case (nil, _?):
|
||||
return false
|
||||
case (nil, nil):
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreAnnotation(_ record: AnnotationUndoRecord) {
|
||||
if record.annotation.page !== record.page {
|
||||
record.page.addAnnotation(record.annotation)
|
||||
}
|
||||
|
||||
for popup in record.popups where popup.page == nil {
|
||||
record.page.addAnnotation(popup)
|
||||
}
|
||||
|
||||
moveAnnotation(record.annotation, on: record.page, to: record.index)
|
||||
detachPopupMarkerFromViewer(for: record.annotation, on: record.page)
|
||||
hasUnsavedChanges = true
|
||||
pdfView?.annotationsChanged(on: record.page)
|
||||
}
|
||||
|
||||
private func moveAnnotation(_ annotation: PDFAnnotation, on page: PDFPage, to index: Int?) {
|
||||
guard let index,
|
||||
index >= 0,
|
||||
index < page.annotations.count - 1
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let tail = page.annotations.dropFirst(index).filter { $0 !== annotation }
|
||||
for annotation in tail {
|
||||
page.removeAnnotation(annotation)
|
||||
}
|
||||
for annotation in tail {
|
||||
page.addAnnotation(annotation)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearStateForRemovedAnnotations(_ removedAnnotations: [PDFAnnotation]) {
|
||||
let removed = Set(removedAnnotations.map(ObjectIdentifier.init))
|
||||
let removedIDs = Set(annotations.compactMap { snapshot in
|
||||
removed.contains(ObjectIdentifier(snapshot.annotation)) ? snapshot.id : nil
|
||||
})
|
||||
|
||||
if selectedAnnotationID.map(removedIDs.contains) == true {
|
||||
selectedAnnotationID = nil
|
||||
}
|
||||
if hoveredAnnotationID.map(removedIDs.contains) == true {
|
||||
hoveredAnnotationID = nil
|
||||
}
|
||||
clearSidebarReplyDraftIfNeeded(deleting: removedIDs)
|
||||
}
|
||||
|
||||
private func addActionName(for style: MarkupAnnotationStyle) -> String {
|
||||
switch style {
|
||||
case .highlight:
|
||||
return "Add Highlight"
|
||||
case .comment:
|
||||
return "Add Comment"
|
||||
case .underline:
|
||||
return "Add Underline Comment"
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTextSelectionState() {
|
||||
@@ -1768,16 +2020,47 @@ final class AppState: NSObject, ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
private func removeAnnotation(_ annotation: PDFAnnotation, from page: PDFPage) {
|
||||
let linkedPopups = page.annotations.filter { candidate in
|
||||
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
|
||||
return candidate === annotation.popup || AnnotationFactory.parentAnnotation(for: candidate) === annotation
|
||||
private func deleteActionName(for item: AnnotationSnapshot, targetCount: Int) -> String {
|
||||
"Delete \(deleteNounTitle(for: item, targetCount: targetCount))"
|
||||
}
|
||||
|
||||
private func deletingActionPhrase(for item: AnnotationSnapshot, targetCount: Int) -> String {
|
||||
"deleting this \(deleteNounSentence(for: item, targetCount: targetCount))"
|
||||
}
|
||||
|
||||
private func deleteStatusMessage(for item: AnnotationSnapshot, targetCount: Int) -> String {
|
||||
"\(deleteNounTitle(for: item, targetCount: targetCount)) deleted."
|
||||
}
|
||||
|
||||
private func deleteNounTitle(for item: AnnotationSnapshot, targetCount: Int) -> String {
|
||||
if targetCount > 1 {
|
||||
return "Comment Thread"
|
||||
}
|
||||
|
||||
for popup in linkedPopups {
|
||||
page.removeAnnotation(popup)
|
||||
switch item.kind {
|
||||
case .comment:
|
||||
return "Comment"
|
||||
case .highlight:
|
||||
return "Highlight"
|
||||
case .underline:
|
||||
return "Underline Comment"
|
||||
case .note:
|
||||
return "Note"
|
||||
case .freeText:
|
||||
return "Free Text"
|
||||
case .reply:
|
||||
return "Reply"
|
||||
case .other:
|
||||
return "Annotation"
|
||||
}
|
||||
if let popup = annotation.popup, popup.page != nil {
|
||||
}
|
||||
|
||||
private func deleteNounSentence(for item: AnnotationSnapshot, targetCount: Int) -> String {
|
||||
deleteNounTitle(for: item, targetCount: targetCount).lowercased()
|
||||
}
|
||||
|
||||
private func removeAnnotation(_ annotation: PDFAnnotation, from page: PDFPage) {
|
||||
for popup in linkedPopups(for: annotation, on: page) {
|
||||
page.removeAnnotation(popup)
|
||||
}
|
||||
|
||||
@@ -1786,6 +2069,22 @@ final class AppState: NSObject, ObservableObject {
|
||||
pdfView?.annotationsChanged(on: page)
|
||||
}
|
||||
|
||||
private func linkedPopups(for annotation: PDFAnnotation, on page: PDFPage) -> [PDFAnnotation] {
|
||||
var seen = Set<ObjectIdentifier>()
|
||||
var popups = page.annotations.filter { candidate in
|
||||
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
|
||||
return candidate === annotation.popup || AnnotationFactory.parentAnnotation(for: candidate) === annotation
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
popups.append(popup)
|
||||
}
|
||||
|
||||
return popups.filter { popup in
|
||||
seen.insert(ObjectIdentifier(popup)).inserted
|
||||
}
|
||||
}
|
||||
|
||||
private func openEditor(
|
||||
title: String,
|
||||
annotations: [PDFAnnotation],
|
||||
|
||||
@@ -88,6 +88,19 @@ private final class AppStateRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
func appStateForActiveWindow() -> AppState? {
|
||||
prune()
|
||||
|
||||
let candidateWindows = [NSApp.keyWindow, NSApp.mainWindow].compactMap { $0 }
|
||||
for window in candidateWindows {
|
||||
if let appState = appStates.compactMap(\.value).first(where: { $0.hostingWindow === window }) {
|
||||
return appState
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func prune() {
|
||||
appStates.removeAll { $0.value == nil }
|
||||
}
|
||||
@@ -241,7 +254,11 @@ private final class WindowCloseGuardView: NSView {
|
||||
}
|
||||
|
||||
private struct AppCommands: Commands {
|
||||
@FocusedObject private var appState: AppState?
|
||||
@FocusedObject private var focusedAppState: AppState?
|
||||
|
||||
private var appState: AppState? {
|
||||
focusedAppState ?? AppStateRegistry.shared.appStateForActiveWindow()
|
||||
}
|
||||
|
||||
private var hasDocument: Bool {
|
||||
appState?.document != nil
|
||||
@@ -259,6 +276,10 @@ private struct AppCommands: Commands {
|
||||
appState?.canSaveDocument == true
|
||||
}
|
||||
|
||||
private var canDeleteSelectedAnnotation: Bool {
|
||||
appState?.canDeleteSelectedAnnotation == true
|
||||
}
|
||||
|
||||
private var recentDocumentURLs: [URL] {
|
||||
appState?.recentDocumentURLs ?? []
|
||||
}
|
||||
@@ -408,6 +429,26 @@ private struct AppCommands: Commands {
|
||||
}
|
||||
|
||||
CommandMenu("Annotate") {
|
||||
Button("Undo Annotation Change") {
|
||||
appState?.undoAnnotationChange()
|
||||
}
|
||||
.disabled(appState?.canUndoAnnotationChange != true)
|
||||
|
||||
Button("Redo Annotation Change") {
|
||||
appState?.redoAnnotationChange()
|
||||
}
|
||||
.disabled(appState?.canRedoAnnotationChange != true)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Cancel Annotation Mode") {
|
||||
appState?.cancelActiveMode()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.disabled(appState?.canCancelActiveMode != true)
|
||||
|
||||
Divider()
|
||||
|
||||
Button(isHighlighterModeActive ? "Turn Highlighter Off" : "Turn Highlighter On") {
|
||||
appState?.toggleHighlighterMode()
|
||||
}
|
||||
@@ -431,6 +472,13 @@ private struct AppCommands: Commands {
|
||||
}
|
||||
.keyboardShortcut("t", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Delete Selected Annotation") {
|
||||
appState?.deleteSelectedAnnotation()
|
||||
}
|
||||
.disabled(!canDeleteSelectedAnnotation)
|
||||
}
|
||||
|
||||
CommandMenu("Bookmark") {
|
||||
|
||||
@@ -6,7 +6,7 @@ import SwiftUI
|
||||
final class AcademicPDFView: PDFView {
|
||||
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
|
||||
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
|
||||
var onCancelPlacement: (() -> Void)?
|
||||
var onCancelActiveMode: (() -> Void)?
|
||||
var onSelectionComment: (() -> Void)?
|
||||
var onHighlighterSelection: (() -> Void)?
|
||||
var onToggleHighlighterKey: (() -> Void)?
|
||||
@@ -14,6 +14,7 @@ final class AcademicPDFView: PDFView {
|
||||
var onCommentSelectionKey: (() -> Void)?
|
||||
var onPreviousPageKey: (() -> Void)?
|
||||
var onNextPageKey: (() -> Void)?
|
||||
var onDeleteSelectedAnnotationKey: (() -> Void)?
|
||||
var placementTool: AnnotationPlacementTool? {
|
||||
didSet {
|
||||
guard oldValue != placementTool else { return }
|
||||
@@ -118,8 +119,14 @@ final class AcademicPDFView: PDFView {
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if event.keyCode == 53, placementTool != nil {
|
||||
onCancelPlacement?()
|
||||
if event.keyCode == 53, placementTool != nil || isHighlighterModeActive {
|
||||
onCancelActiveMode?()
|
||||
return
|
||||
}
|
||||
|
||||
if [51, 117].contains(event.keyCode),
|
||||
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty {
|
||||
onDeleteSelectedAnnotationKey?()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -506,9 +513,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
appState.placePendingAnnotation(on: page, near: point)
|
||||
}
|
||||
}
|
||||
view.onCancelPlacement = {
|
||||
view.onCancelActiveMode = {
|
||||
Task { @MainActor in
|
||||
appState.cancelPlacementTool()
|
||||
appState.cancelActiveMode()
|
||||
}
|
||||
}
|
||||
view.onSelectionComment = {
|
||||
@@ -546,6 +553,11 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
appState.goToNextPage()
|
||||
}
|
||||
}
|
||||
view.onDeleteSelectedAnnotationKey = {
|
||||
Task { @MainActor in
|
||||
appState.deleteSelectedAnnotation()
|
||||
}
|
||||
}
|
||||
appState.attachPDFView(view)
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -314,6 +314,7 @@ private struct HighlightGroupView: View {
|
||||
private struct HighlightRow: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var isRowHovered = false
|
||||
let item: AnnotationSnapshot
|
||||
let showsColorSwatch: Bool
|
||||
|
||||
@@ -322,49 +323,80 @@ private struct HighlightRow: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
appState.selectHighlightedText(item)
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.id == appState.selectedAnnotationID ? "largecircle.fill.circle" : "circle")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(item.id == appState.selectedAnnotationID ? Color.accentColor : InterfacePalette.quietText(for: colorScheme))
|
||||
.frame(width: 14, height: 18)
|
||||
|
||||
if showsColorSwatch {
|
||||
Capsule()
|
||||
.fill(swatchColor)
|
||||
.frame(width: 20, height: 6)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 0.6)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("p. \(item.pageLabel)")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||
|
||||
Text(item.highlightExcerpt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Button {
|
||||
appState.selectHighlightedText(item)
|
||||
} label: {
|
||||
highlightSummary
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
|
||||
.buttonStyle(.plain)
|
||||
.help("Go to highlight on page \(item.pageLabel)")
|
||||
|
||||
Button(role: .destructive) {
|
||||
appState.delete(item)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.frame(width: 20, height: 20)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||
.help("Delete Highlight")
|
||||
.accessibilityLabel("Delete Highlight")
|
||||
.opacity(isRowHovered || item.id == appState.selectedAnnotationID ? 1 : 0.68)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Go to highlight on page \(item.pageLabel)")
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
|
||||
.onHover { isHovered in
|
||||
isRowHovered = isHovered
|
||||
appState.setCommentHover(item, isHovered: isHovered)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
appState.delete(item)
|
||||
} label: {
|
||||
Label("Delete Highlight", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var highlightSummary: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.id == appState.selectedAnnotationID ? "largecircle.fill.circle" : "circle")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(item.id == appState.selectedAnnotationID ? Color.accentColor : InterfacePalette.quietText(for: colorScheme))
|
||||
.frame(width: 14, height: 18)
|
||||
|
||||
if showsColorSwatch {
|
||||
Capsule()
|
||||
.fill(swatchColor)
|
||||
.frame(width: 20, height: 6)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 0.6)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("p. \(item.pageLabel)")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||
|
||||
Text(item.highlightExcerpt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1287,26 +1319,41 @@ private struct CommentReviewRowActions: View {
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button(role: .none, action: onEdit) {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .none, action: onReply) {
|
||||
HStack(spacing: 5) {
|
||||
Button(action: onReply) {
|
||||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||
.font(.caption2.weight(.medium))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
.buttonStyle(.plain)
|
||||
.help("Reply")
|
||||
.accessibilityLabel("Reply")
|
||||
|
||||
Menu {
|
||||
Button(role: .none, action: onEdit) {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .none, action: onReply) {
|
||||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.frame(width: 18, height: 18)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.frame(width: 18, height: 18)
|
||||
.contentShape(Rectangle())
|
||||
.menuStyle(.borderlessButton)
|
||||
.buttonStyle(.plain)
|
||||
.menuIndicator(.hidden)
|
||||
.opacity(isVisible ? 1 : 0)
|
||||
.animation(.easeInOut(duration: 0.12), value: isVisible)
|
||||
.help("More Actions")
|
||||
.accessibilityLabel("More Actions")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.buttonStyle(.plain)
|
||||
.menuIndicator(.hidden)
|
||||
.opacity(isVisible ? 1 : 0)
|
||||
.animation(.easeInOut(duration: 0.12), value: isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,69 @@ final class AppStateWorkflowTests: XCTestCase {
|
||||
XCTAssertEqual(appState.saveHelpText, "Save PDF")
|
||||
}
|
||||
|
||||
func testCancelActiveModeClearsHighlighterAndPlacement() {
|
||||
let appState = AppState()
|
||||
appState.isHighlighterModeActive = true
|
||||
appState.placementTool = .freeText
|
||||
|
||||
XCTAssertTrue(appState.canCancelActiveMode)
|
||||
XCTAssertTrue(appState.cancelActiveMode())
|
||||
|
||||
XCTAssertFalse(appState.isHighlighterModeActive)
|
||||
XCTAssertNil(appState.placementTool)
|
||||
XCTAssertEqual(appState.statusMessage, "Free text placement canceled. Highlighter off.")
|
||||
XCTAssertFalse(appState.canCancelActiveMode)
|
||||
}
|
||||
|
||||
func testCancelActiveModeReturnsFalseWhenNoModeIsActive() {
|
||||
let appState = AppState()
|
||||
|
||||
XCTAssertFalse(appState.canCancelActiveMode)
|
||||
XCTAssertFalse(appState.cancelActiveMode())
|
||||
XCTAssertEqual(appState.statusMessage, "Open a PDF to begin.")
|
||||
}
|
||||
|
||||
func testDeleteSelectedHighlightCanUndoAndRedo() throws {
|
||||
let url = try makeTemporaryPDF()
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
let window = NSWindow()
|
||||
let appState = AppState()
|
||||
appState.hostingWindow = window
|
||||
appState.loadDocument(from: url)
|
||||
|
||||
let page = try XCTUnwrap(appState.document?.page(at: 0))
|
||||
let highlight = PDFAnnotation(
|
||||
bounds: CGRect(x: 72, y: 650, width: 180, height: 18),
|
||||
forType: .highlight,
|
||||
withProperties: nil
|
||||
)
|
||||
highlight.markupType = .highlight
|
||||
page.addAnnotation(highlight)
|
||||
appState.refreshAnnotations(on: [page])
|
||||
|
||||
let item = try XCTUnwrap(appState.annotations.first { $0.annotation === highlight })
|
||||
appState.selectHighlightedText(item)
|
||||
XCTAssertTrue(appState.canDeleteSelectedAnnotation)
|
||||
|
||||
appState.deleteSelectedAnnotation()
|
||||
|
||||
XCTAssertFalse(page.annotations.contains { $0 === highlight })
|
||||
XCTAssertTrue(appState.annotations.isEmpty)
|
||||
XCTAssertTrue(appState.canUndoAnnotationChange)
|
||||
|
||||
appState.undoAnnotationChange()
|
||||
|
||||
XCTAssertTrue(page.annotations.contains { $0 === highlight })
|
||||
XCTAssertEqual(appState.annotations.count, 1)
|
||||
XCTAssertTrue(appState.canRedoAnnotationChange)
|
||||
|
||||
appState.redoAnnotationChange()
|
||||
|
||||
XCTAssertFalse(page.annotations.contains { $0 === highlight })
|
||||
XCTAssertTrue(appState.annotations.isEmpty)
|
||||
}
|
||||
|
||||
private func makeTemporaryPDF() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
Reference in New Issue
Block a user