Fix v0.4 reader workflow issues

This commit is contained in:
Akshay Kolli
2026-06-30 10:36:12 -07:00
parent 9bdc1a4b09
commit c62f0bf9c7
9 changed files with 568 additions and 91 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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}}"

View File

@@ -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],

View File

@@ -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") {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)