diff --git a/CHANGELOG.md b/CHANGELOG.md index b191bd6..39108cf 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/README.md b/README.md index 90d8251..b4168f3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/QA.md b/docs/QA.md index 9715cd1..103bb30 100644 --- a/docs/QA.md +++ b/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 diff --git a/scripts/release-version.sh b/scripts/release-version.sh index aac95c2..4428ef2 100755 --- a/scripts/release-version.sh +++ b/scripts/release-version.sh @@ -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}}" diff --git a/sources/app/AppState.swift b/sources/app/AppState.swift index 3833b6a..35a1544 100644 --- a/sources/app/AppState.swift +++ b/sources/app/AppState.swift @@ -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() + 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() + 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], diff --git a/sources/app/IHatePDFsApp.swift b/sources/app/IHatePDFsApp.swift index 0fda934..c134f2d 100644 --- a/sources/app/IHatePDFsApp.swift +++ b/sources/app/IHatePDFsApp.swift @@ -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") { diff --git a/sources/app/PDFKitRepresentedView.swift b/sources/app/PDFKitRepresentedView.swift index 5773cad..2593dd5 100644 --- a/sources/app/PDFKitRepresentedView.swift +++ b/sources/app/PDFKitRepresentedView.swift @@ -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 } diff --git a/sources/app/SidebarViews.swift b/sources/app/SidebarViews.swift index 7aeefc2..f7bf021 100644 --- a/sources/app/SidebarViews.swift +++ b/sources/app/SidebarViews.swift @@ -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) } } diff --git a/tests/app/AppStateWorkflowTests.swift b/tests/app/AppStateWorkflowTests.swift index 64cd98d..92fbdbc 100644 --- a/tests/app/AppStateWorkflowTests.swift +++ b/tests/app/AppStateWorkflowTests.swift @@ -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)