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.
|
- 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.
|
- 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.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.
|
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
|
## 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:
|
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
|
## 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.
|
- 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.
|
- 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.
|
- 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
|
## Latest v0.4 Automated QA Run
|
||||||
|
|
||||||
Completed on 2026-06-29:
|
Completed on 2026-06-30 for build 7:
|
||||||
|
|
||||||
- `swift build`
|
|
||||||
- `swift test`
|
- `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:
|
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`.
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Files
|
## Test Files
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
APP_VERSION="${APP_VERSION:-0.4.0}"
|
APP_VERSION="${APP_VERSION:-0.4.0}"
|
||||||
BUILD_NUMBER="${BUILD_NUMBER:-6}"
|
BUILD_NUMBER="${BUILD_NUMBER:-7}"
|
||||||
RELEASE_VERSION="${RELEASE_VERSION:-${APP_VERSION%.0}}"
|
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 {
|
private enum AppDefaults {
|
||||||
static let documentPageProgress = "IHatePDFs.documentPageProgress.v1"
|
static let documentPageProgress = "IHatePDFs.documentPageProgress.v1"
|
||||||
static let documentBookmarks = "IHatePDFs.documentBookmarks.v1"
|
static let documentBookmarks = "IHatePDFs.documentBookmarks.v1"
|
||||||
@@ -330,10 +337,28 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
&& !isHighlighterModeActive
|
&& !isHighlighterModeActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canCancelActiveMode: Bool {
|
||||||
|
placementTool != nil || isHighlighterModeActive
|
||||||
|
}
|
||||||
|
|
||||||
var canClearSearchQuery: Bool {
|
var canClearSearchQuery: Bool {
|
||||||
!searchText.isEmpty || !searchResults.isEmpty
|
!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? {
|
var searchSummaryText: String? {
|
||||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !query.isEmpty else { return nil }
|
guard !query.isEmpty else { return nil }
|
||||||
@@ -378,6 +403,10 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
return [ReviewState.allStatuses] + preferred + custom
|
return [ReviewState.allStatuses] + preferred + custom
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var annotationUndoManager: UndoManager? {
|
||||||
|
pdfView?.undoManager ?? hostingWindow?.undoManager
|
||||||
|
}
|
||||||
|
|
||||||
var currentPageBookmark: PDFDocumentBookmark? {
|
var currentPageBookmark: PDFDocumentBookmark? {
|
||||||
PDFDocumentBookmarks.bookmark(on: currentPageIndex, in: bookmarks)
|
PDFDocumentBookmarks.bookmark(on: currentPageIndex, in: bookmarks)
|
||||||
}
|
}
|
||||||
@@ -1065,6 +1094,25 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
statusMessage = placementTool.cancellationMessage
|
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) {
|
func placePendingAnnotation(on page: PDFPage, near point: CGPoint) {
|
||||||
guard let placementTool else { return }
|
guard let placementTool else { return }
|
||||||
|
|
||||||
@@ -1082,7 +1130,8 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.placementTool = nil
|
self.placementTool = nil
|
||||||
add(insertion)
|
let record = add(insertion)
|
||||||
|
registerUndoToRemoveAnnotations([record], actionName: "Add Free Text")
|
||||||
refreshAnnotations(on: [page])
|
refreshAnnotations(on: [page])
|
||||||
openEditor(
|
openEditor(
|
||||||
title: "Free Text",
|
title: "Free Text",
|
||||||
@@ -1157,7 +1206,8 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
author: author.isEmpty ? AnnotationFactory.defaultAuthor : author,
|
author: author.isEmpty ? AnnotationFactory.defaultAuthor : author,
|
||||||
parentID: parent.id
|
parentID: parent.id
|
||||||
)
|
)
|
||||||
add(insertion)
|
let record = add(insertion)
|
||||||
|
registerUndoToRemoveAnnotations([record], actionName: "Add Reply")
|
||||||
clearSidebarReplyDraft()
|
clearSidebarReplyDraft()
|
||||||
refreshAnnotations(on: [parent.page])
|
refreshAnnotations(on: [parent.page])
|
||||||
|
|
||||||
@@ -1205,17 +1255,20 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
let targetIDs = Set(targets.map(\.id))
|
let targetIDs = Set(targets.map(\.id))
|
||||||
let targetPages = targets.map(\.page)
|
let targetPages = targets.map(\.page)
|
||||||
|
let actionName = deleteActionName(for: item, targetCount: targets.count)
|
||||||
|
|
||||||
guard confirmDiscardSidebarReplyDraftIfNeeded(
|
guard confirmDiscardSidebarReplyDraftIfNeeded(
|
||||||
deleting: targetIDs,
|
deleting: targetIDs,
|
||||||
actionName: targets.count > 1 ? "deleting this comment thread" : "deleting this comment"
|
actionName: deletingActionPhrase(for: item, targetCount: targets.count)
|
||||||
) else {
|
) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let records = annotationUndoRecords(for: targets)
|
||||||
for target in targets {
|
for target in targets {
|
||||||
removeAnnotation(target.annotation, from: target.page)
|
removeAnnotation(target.annotation, from: target.page)
|
||||||
}
|
}
|
||||||
|
registerUndoToRestoreAnnotations(records, actionName: actionName)
|
||||||
|
|
||||||
if selectedAnnotationID.map(targetIDs.contains) == true {
|
if selectedAnnotationID.map(targetIDs.contains) == true {
|
||||||
selectedAnnotationID = nil
|
selectedAnnotationID = nil
|
||||||
@@ -1227,7 +1280,27 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
|
|
||||||
activeEditor = nil
|
activeEditor = nil
|
||||||
refreshAnnotations(on: targetPages)
|
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) {
|
func toggleReviewed(_ item: AnnotationSnapshot) {
|
||||||
@@ -1295,14 +1368,24 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
let targetIDs = Set(targets.map(\.id))
|
let targetIDs = Set(targets.map(\.id))
|
||||||
let targetPages = targets.map(\.page)
|
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(
|
guard confirmDiscardSidebarReplyDraftIfNeeded(
|
||||||
deleting: targetIDs,
|
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 {
|
) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let records = targets.isEmpty
|
||||||
|
? annotationUndoRecords(for: context.annotations, pages: context.pages)
|
||||||
|
: annotationUndoRecords(for: targets)
|
||||||
|
|
||||||
if targets.isEmpty {
|
if targets.isEmpty {
|
||||||
for (index, annotation) in context.annotations.enumerated() {
|
for (index, annotation) in context.annotations.enumerated() {
|
||||||
guard index < context.pages.count else { continue }
|
guard index < context.pages.count else { continue }
|
||||||
@@ -1313,6 +1396,7 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
removeAnnotation(target.annotation, from: target.page)
|
removeAnnotation(target.annotation, from: target.page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
registerUndoToRestoreAnnotations(records, actionName: actionName)
|
||||||
|
|
||||||
activeEditor = nil
|
activeEditor = nil
|
||||||
if targetIDs.isEmpty || selectedAnnotationID.map(targetIDs.contains) == true {
|
if targetIDs.isEmpty || selectedAnnotationID.map(targetIDs.contains) == true {
|
||||||
@@ -1326,7 +1410,9 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
if context.isNewAnnotation {
|
if context.isNewAnnotation {
|
||||||
hasUnsavedChanges = context.hadUnsavedChangesBeforeCreation
|
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) {
|
func select(_ item: AnnotationSnapshot) {
|
||||||
@@ -1626,9 +1712,11 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hadUnsavedChangesBeforeCreation = hasUnsavedChanges
|
let hadUnsavedChangesBeforeCreation = hasUnsavedChanges
|
||||||
|
var records: [AnnotationUndoRecord] = []
|
||||||
for insertion in insertions {
|
for insertion in insertions {
|
||||||
add(insertion)
|
records.append(add(insertion))
|
||||||
}
|
}
|
||||||
|
registerUndoToRemoveAnnotations(records, actionName: addActionName(for: style))
|
||||||
pdfView?.clearSelection()
|
pdfView?.clearSelection()
|
||||||
updateTextSelectionState()
|
updateTextSelectionState()
|
||||||
refreshAnnotations(on: insertions.map(\.page))
|
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)
|
insertion.page.addAnnotation(insertion.annotation)
|
||||||
if AnnotationKeys.isReply(insertion.annotation) {
|
if AnnotationKeys.isReply(insertion.annotation) {
|
||||||
AnnotationFactory.hideReplyMarker(insertion.annotation, on: insertion.page)
|
AnnotationFactory.hideReplyMarker(insertion.annotation, on: insertion.page)
|
||||||
@@ -1662,6 +1751,169 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
detachPopupMarkerFromViewer(for: insertion.annotation, on: insertion.page)
|
detachPopupMarkerFromViewer(for: insertion.annotation, on: insertion.page)
|
||||||
hasUnsavedChanges = true
|
hasUnsavedChanges = true
|
||||||
pdfView?.annotationsChanged(on: insertion.page)
|
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() {
|
private func updateTextSelectionState() {
|
||||||
@@ -1768,16 +2020,47 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeAnnotation(_ annotation: PDFAnnotation, from page: PDFPage) {
|
private func deleteActionName(for item: AnnotationSnapshot, targetCount: Int) -> String {
|
||||||
let linkedPopups = page.annotations.filter { candidate in
|
"Delete \(deleteNounTitle(for: item, targetCount: targetCount))"
|
||||||
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
|
}
|
||||||
return candidate === annotation.popup || AnnotationFactory.parentAnnotation(for: candidate) === annotation
|
|
||||||
|
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 {
|
switch item.kind {
|
||||||
page.removeAnnotation(popup)
|
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)
|
page.removeAnnotation(popup)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1786,6 +2069,22 @@ final class AppState: NSObject, ObservableObject {
|
|||||||
pdfView?.annotationsChanged(on: page)
|
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(
|
private func openEditor(
|
||||||
title: String,
|
title: String,
|
||||||
annotations: [PDFAnnotation],
|
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() {
|
private func prune() {
|
||||||
appStates.removeAll { $0.value == nil }
|
appStates.removeAll { $0.value == nil }
|
||||||
}
|
}
|
||||||
@@ -241,7 +254,11 @@ private final class WindowCloseGuardView: NSView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct AppCommands: Commands {
|
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 {
|
private var hasDocument: Bool {
|
||||||
appState?.document != nil
|
appState?.document != nil
|
||||||
@@ -259,6 +276,10 @@ private struct AppCommands: Commands {
|
|||||||
appState?.canSaveDocument == true
|
appState?.canSaveDocument == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var canDeleteSelectedAnnotation: Bool {
|
||||||
|
appState?.canDeleteSelectedAnnotation == true
|
||||||
|
}
|
||||||
|
|
||||||
private var recentDocumentURLs: [URL] {
|
private var recentDocumentURLs: [URL] {
|
||||||
appState?.recentDocumentURLs ?? []
|
appState?.recentDocumentURLs ?? []
|
||||||
}
|
}
|
||||||
@@ -408,6 +429,26 @@ private struct AppCommands: Commands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CommandMenu("Annotate") {
|
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") {
|
Button(isHighlighterModeActive ? "Turn Highlighter Off" : "Turn Highlighter On") {
|
||||||
appState?.toggleHighlighterMode()
|
appState?.toggleHighlighterMode()
|
||||||
}
|
}
|
||||||
@@ -431,6 +472,13 @@ private struct AppCommands: Commands {
|
|||||||
}
|
}
|
||||||
.keyboardShortcut("t", modifiers: [.command, .shift])
|
.keyboardShortcut("t", modifiers: [.command, .shift])
|
||||||
.disabled(!hasDocument)
|
.disabled(!hasDocument)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Delete Selected Annotation") {
|
||||||
|
appState?.deleteSelectedAnnotation()
|
||||||
|
}
|
||||||
|
.disabled(!canDeleteSelectedAnnotation)
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandMenu("Bookmark") {
|
CommandMenu("Bookmark") {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import SwiftUI
|
|||||||
final class AcademicPDFView: PDFView {
|
final class AcademicPDFView: PDFView {
|
||||||
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
|
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
|
||||||
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
|
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
|
||||||
var onCancelPlacement: (() -> Void)?
|
var onCancelActiveMode: (() -> Void)?
|
||||||
var onSelectionComment: (() -> Void)?
|
var onSelectionComment: (() -> Void)?
|
||||||
var onHighlighterSelection: (() -> Void)?
|
var onHighlighterSelection: (() -> Void)?
|
||||||
var onToggleHighlighterKey: (() -> Void)?
|
var onToggleHighlighterKey: (() -> Void)?
|
||||||
@@ -14,6 +14,7 @@ final class AcademicPDFView: PDFView {
|
|||||||
var onCommentSelectionKey: (() -> Void)?
|
var onCommentSelectionKey: (() -> Void)?
|
||||||
var onPreviousPageKey: (() -> Void)?
|
var onPreviousPageKey: (() -> Void)?
|
||||||
var onNextPageKey: (() -> Void)?
|
var onNextPageKey: (() -> Void)?
|
||||||
|
var onDeleteSelectedAnnotationKey: (() -> Void)?
|
||||||
var placementTool: AnnotationPlacementTool? {
|
var placementTool: AnnotationPlacementTool? {
|
||||||
didSet {
|
didSet {
|
||||||
guard oldValue != placementTool else { return }
|
guard oldValue != placementTool else { return }
|
||||||
@@ -118,8 +119,14 @@ final class AcademicPDFView: PDFView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func keyDown(with event: NSEvent) {
|
override func keyDown(with event: NSEvent) {
|
||||||
if event.keyCode == 53, placementTool != nil {
|
if event.keyCode == 53, placementTool != nil || isHighlighterModeActive {
|
||||||
onCancelPlacement?()
|
onCancelActiveMode?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if [51, 117].contains(event.keyCode),
|
||||||
|
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty {
|
||||||
|
onDeleteSelectedAnnotationKey?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,9 +513,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
|||||||
appState.placePendingAnnotation(on: page, near: point)
|
appState.placePendingAnnotation(on: page, near: point)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
view.onCancelPlacement = {
|
view.onCancelActiveMode = {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
appState.cancelPlacementTool()
|
appState.cancelActiveMode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
view.onSelectionComment = {
|
view.onSelectionComment = {
|
||||||
@@ -546,6 +553,11 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
|||||||
appState.goToNextPage()
|
appState.goToNextPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
view.onDeleteSelectedAnnotationKey = {
|
||||||
|
Task { @MainActor in
|
||||||
|
appState.deleteSelectedAnnotation()
|
||||||
|
}
|
||||||
|
}
|
||||||
appState.attachPDFView(view)
|
appState.attachPDFView(view)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ private struct HighlightGroupView: View {
|
|||||||
private struct HighlightRow: View {
|
private struct HighlightRow: View {
|
||||||
@EnvironmentObject private var appState: AppState
|
@EnvironmentObject private var appState: AppState
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@State private var isRowHovered = false
|
||||||
let item: AnnotationSnapshot
|
let item: AnnotationSnapshot
|
||||||
let showsColorSwatch: Bool
|
let showsColorSwatch: Bool
|
||||||
|
|
||||||
@@ -322,49 +323,80 @@ private struct HighlightRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
HStack(alignment: .top, spacing: 6) {
|
||||||
appState.selectHighlightedText(item)
|
Button {
|
||||||
} label: {
|
appState.selectHighlightedText(item)
|
||||||
HStack(alignment: .top, spacing: 8) {
|
} label: {
|
||||||
Image(systemName: item.id == appState.selectedAnnotationID ? "largecircle.fill.circle" : "circle")
|
highlightSummary
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
.buttonStyle(.plain)
|
||||||
.padding(.vertical, 7)
|
.help("Go to highlight on page \(item.pageLabel)")
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.contentShape(Rectangle())
|
Button(role: .destructive) {
|
||||||
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
|
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)
|
.padding(.horizontal, 10)
|
||||||
.help("Go to highlight on page \(item.pageLabel)")
|
.padding(.vertical, 7)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
|
||||||
.onHover { isHovered in
|
.onHover { isHovered in
|
||||||
|
isRowHovered = isHovered
|
||||||
appState.setCommentHover(item, isHovered: 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
|
let onDelete: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Menu {
|
HStack(spacing: 5) {
|
||||||
Button(role: .none, action: onEdit) {
|
Button(action: onReply) {
|
||||||
Label("Edit", systemImage: "pencil")
|
|
||||||
}
|
|
||||||
Button(role: .none, action: onReply) {
|
|
||||||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
Button(role: .destructive, action: onDelete) {
|
.buttonStyle(.plain)
|
||||||
Label("Delete", systemImage: "trash")
|
.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: {
|
.menuStyle(.borderlessButton)
|
||||||
Image(systemName: "ellipsis")
|
.buttonStyle(.plain)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.menuIndicator(.hidden)
|
||||||
.frame(width: 18, height: 18)
|
.opacity(isVisible ? 1 : 0)
|
||||||
.contentShape(Rectangle())
|
.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")
|
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 {
|
private func makeTemporaryPDF() throws -> URL {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent(UUID().uuidString)
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
|||||||
Reference in New Issue
Block a user