Release v0.3

This commit is contained in:
Akshay Kolli
2026-06-24 17:51:26 -07:00
parent 3d112c677a
commit 085d7a16dc
33 changed files with 2828 additions and 428 deletions

View File

@@ -0,0 +1,121 @@
import AppKit
import Foundation
import IHatePDFsCore
import SwiftUI
enum AppSettings {
static let highlightColorStorageKey = "IHatePDFs.highlightColorRGBA.v1"
static let commentColorStorageKey = "IHatePDFs.commentColorRGBA.v1"
static let defaultHighlightColorStorageValue = storageString(for: AcademicAnnotationPalette.highlight)
static let defaultCommentColorStorageValue = storageString(for: AcademicAnnotationPalette.comment)
private static let minimumHighlightAlpha: CGFloat = 0.38
private static let minimumCommentAlpha: CGFloat = 0.12
static var highlightColor: NSColor {
get {
highlightColor(from: UserDefaults.standard.string(forKey: highlightColorStorageKey))
}
set {
UserDefaults.standard.set(storageString(forHighlightColor: newValue), forKey: highlightColorStorageKey)
}
}
static var commentColor: NSColor {
get {
commentColor(from: UserDefaults.standard.string(forKey: commentColorStorageKey))
}
set {
UserDefaults.standard.set(storageString(forCommentColor: newValue), forKey: commentColorStorageKey)
}
}
static func highlightColor(from storageValue: String?) -> NSColor {
AnnotationColorPreference.color(
from: storageValue,
fallback: AcademicAnnotationPalette.highlight,
minimumAlpha: minimumHighlightAlpha
)
}
static func commentColor(from storageValue: String?) -> NSColor {
AnnotationColorPreference.color(
from: storageValue,
fallback: AcademicAnnotationPalette.comment,
minimumAlpha: minimumCommentAlpha
)
}
static func storageString(for color: NSColor) -> String {
AnnotationColorPreference.storageString(for: color)
}
static func storageString(for color: Color) -> String {
storageString(for: NSColor(color))
}
static func storageString(forHighlightColor color: NSColor) -> String {
storageString(for: highlightColor(from: storageString(for: color)))
}
static func storageString(forHighlightColor color: Color) -> String {
storageString(forHighlightColor: NSColor(color))
}
static func storageString(forCommentColor color: NSColor) -> String {
storageString(for: commentColor(from: storageString(for: color)))
}
static func storageString(forCommentColor color: Color) -> String {
storageString(forCommentColor: NSColor(color))
}
}
struct SettingsView: View {
@AppStorage(AppSettings.highlightColorStorageKey)
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
@AppStorage(AppSettings.commentColorStorageKey)
private var storedCommentColor = AppSettings.defaultCommentColorStorageValue
var body: some View {
Form {
Section("Annotations") {
ColorPicker(
"Highlight color",
selection: highlightColor,
supportsOpacity: true
)
ColorPicker(
"Comment color",
selection: commentColor,
supportsOpacity: true
)
Button {
storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
storedCommentColor = AppSettings.defaultCommentColorStorageValue
} label: {
Label("Reset Annotation Colors", systemImage: "arrow.counterclockwise")
}
}
}
.padding(20)
.frame(width: 360)
}
private var highlightColor: Binding<Color> {
Binding {
Color(nsColor: AppSettings.highlightColor(from: storedHighlightColor))
} set: { newValue in
storedHighlightColor = AppSettings.storageString(forHighlightColor: newValue)
}
}
private var commentColor: Binding<Color> {
Binding {
Color(nsColor: AppSettings.commentColor(from: storedCommentColor))
} set: { newValue in
storedCommentColor = AppSettings.storageString(forCommentColor: newValue)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@ import SwiftUI
@main
struct IHatePDFsApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
AppWindowRoot()
@@ -11,6 +13,71 @@ struct IHatePDFsApp: App {
.commands {
AppCommands()
}
Settings {
SettingsView()
}
}
}
@MainActor
private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
AppStateRegistry.shared.confirmApplicationShouldTerminate()
? .terminateNow
: .terminateCancel
}
}
@MainActor
private final class AppStateRegistry {
static let shared = AppStateRegistry()
private var appStates: [WeakAppState] = []
private(set) var isTerminationApproved = false
func register(_ appState: AppState) {
prune()
guard !appStates.contains(where: { $0.value === appState }) else {
return
}
appStates.append(WeakAppState(appState))
}
func unregister(_ appState: AppState) {
appStates.removeAll { $0.value == nil || $0.value === appState }
}
func confirmApplicationShouldTerminate() -> Bool {
prune()
for appState in appStates.compactMap(\.value) {
guard appState.confirmApplicationQuit() else {
cancelTerminationApproval()
return false
}
}
isTerminationApproved = true
return true
}
func cancelTerminationApproval() {
isTerminationApproved = false
}
private func prune() {
appStates.removeAll { $0.value == nil }
}
}
private final class WeakAppState {
weak var value: AppState?
init(_ value: AppState) {
self.value = value
}
}
@@ -21,9 +88,130 @@ private struct AppWindowRoot: View {
MainView()
.environmentObject(appState)
.focusedObject(appState)
.background(WindowCloseGuard(appState: appState))
.onOpenURL { url in
appState.loadDocument(from: url)
}
.onAppear {
AppStateRegistry.shared.register(appState)
}
.onDisappear {
AppStateRegistry.shared.unregister(appState)
}
}
}
private struct WindowCloseGuard: NSViewRepresentable {
@ObservedObject var appState: AppState
func makeCoordinator() -> Coordinator {
Coordinator(appState: appState)
}
func makeNSView(context: Context) -> WindowCloseGuardView {
let view = WindowCloseGuardView()
view.onWindowChange = { [weak coordinator = context.coordinator] window in
coordinator?.attach(to: window)
}
return view
}
func updateNSView(_ view: WindowCloseGuardView, context: Context) {
context.coordinator.appState = appState
context.coordinator.updateDocumentState()
view.onWindowChange = { [weak coordinator = context.coordinator] window in
coordinator?.attach(to: window)
}
view.reportWindow()
}
@MainActor
final class Coordinator: NSObject, NSWindowDelegate {
weak var appState: AppState?
private weak var window: NSWindow?
private weak var previousDelegate: NSWindowDelegate?
init(appState: AppState) {
self.appState = appState
}
func attach(to window: NSWindow?) {
guard self.window !== window else { return }
if let oldWindow = self.window, oldWindow.delegate === self {
oldWindow.delegate = previousDelegate
}
self.window = window
previousDelegate = window?.delegate
if window?.delegate !== self {
window?.delegate = self
}
updateDocumentState()
}
func updateDocumentState() {
guard let window else { return }
let representedURL = appState?.documentURL
if window.representedURL != representedURL {
window.representedURL = representedURL
}
let isDocumentEdited = appState?.hasUnsavedWork == true
if window.isDocumentEdited != isDocumentEdited {
window.isDocumentEdited = isDocumentEdited
}
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
if previousDelegate?.windowShouldClose?(sender) == false {
AppStateRegistry.shared.cancelTerminationApproval()
return false
}
if AppStateRegistry.shared.isTerminationApproved {
return true
}
return appState?.confirmDocumentWindowClose() ?? true
}
func windowWillClose(_ notification: Notification) {
previousDelegate?.windowWillClose?(notification)
if window?.delegate === self {
window?.delegate = previousDelegate
}
window = nil
previousDelegate = nil
}
deinit {
MainActor.assumeIsolated {
if window?.delegate === self {
window?.delegate = previousDelegate
}
}
}
}
}
private final class WindowCloseGuardView: NSView {
var onWindowChange: ((NSWindow?) -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
reportWindow()
}
func reportWindow() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
onWindowChange?(window)
}
}
}
@@ -34,6 +222,18 @@ private struct AppCommands: Commands {
appState?.document != nil
}
private var hasTextSelection: Bool {
appState?.hasTextSelection == true
}
private var canSaveDocument: Bool {
appState?.canSaveDocument == true
}
private var saveHelpText: String {
appState?.saveHelpText ?? "Open a PDF before saving."
}
var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("Open...") {
@@ -46,7 +246,8 @@ private struct AppCommands: Commands {
appState?.saveDocument()
}
.keyboardShortcut("s")
.disabled(!hasDocument)
.disabled(!canSaveDocument)
.help(saveHelpText)
Button("Save As...") {
appState?.saveDocumentAs()
@@ -62,6 +263,12 @@ private struct AppCommands: Commands {
Divider()
Button("Settings...") {
openSettingsWindow()
}
Divider()
Button("Close PDF") {
appState?.closeDocument()
}
@@ -140,19 +347,19 @@ private struct AppCommands: Commands {
appState?.addHighlight()
}
.keyboardShortcut("h", modifiers: [.command, .shift])
.disabled(!hasDocument)
.disabled(!hasDocument || !hasTextSelection)
Button("Underline Selection") {
appState?.addUnderline()
}
.keyboardShortcut("u", modifiers: [.command, .shift])
.disabled(!hasDocument)
.disabled(!hasDocument || !hasTextSelection)
Button("Comment on Selection") {
appState?.addComment()
}
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(!hasDocument)
.disabled(!hasDocument || !hasTextSelection)
Button("Add Free Text") {
appState?.addFreeText()
@@ -175,4 +382,10 @@ private struct AppCommands: Commands {
.disabled(appState == nil)
}
}
private func openSettingsWindow() {
if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
}
}
}

View File

@@ -1,5 +1,6 @@
import IHatePDFsCore
import SwiftUI
import UniformTypeIdentifiers
struct MainView: View {
@EnvironmentObject private var appState: AppState
@@ -56,12 +57,13 @@ private struct PDFReaderView: View {
private struct EmptyDocumentView: View {
@EnvironmentObject private var appState: AppState
@State private var isDropTargeted = false
var body: some View {
VStack(spacing: 16) {
Image(systemName: "doc.richtext")
Image(systemName: isDropTargeted ? "tray.and.arrow.down" : "doc.richtext")
.font(.system(size: 48, weight: .regular))
.foregroundStyle(.secondary)
.foregroundColor(isDropTargeted ? .accentColor : .secondary)
Text("Open a PDF")
.font(.title2)
@@ -82,6 +84,20 @@ private struct EmptyDocumentView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(
isDropTargeted ? Color.accentColor : Color.clear,
style: StrokeStyle(lineWidth: 2, dash: [8, 6])
)
.padding(18)
}
.onDrop(
of: [UTType.fileURL.identifier],
isTargeted: $isDropTargeted
) { providers in
appState.openDroppedDocument(from: providers)
}
}
}
@@ -97,6 +113,9 @@ private struct StatusBarView: View {
Spacer()
if appState.document != nil {
if appState.hasUnsentSidebarReplyDraft {
Text("Reply draft")
}
Text("\(appState.annotations.count) annotations")
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
}
@@ -146,7 +165,7 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Previous Page", systemImage: "chevron.up")
}
.disabled(appState.document == nil)
.disabled(!appState.canGoToPreviousPage)
.help("Previous Page")
TextField("Page", text: $appState.pageText)
@@ -165,7 +184,7 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Next Page", systemImage: "chevron.down")
}
.disabled(appState.document == nil)
.disabled(!appState.canGoToNextPage)
.help("Next Page")
}
@@ -225,7 +244,7 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Highlight", systemImage: "highlighter")
}
.disabled(appState.document == nil)
.disabled(appState.document == nil || !appState.hasTextSelection)
.help("Highlight Selection")
Button {
@@ -233,7 +252,7 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Underline", systemImage: "underline")
}
.disabled(appState.document == nil)
.disabled(appState.document == nil || !appState.hasTextSelection)
.help("Underline Selection")
Button {
@@ -243,7 +262,7 @@ private struct ReaderToolbar: ToolbarContent {
}
.accessibilityLabel("Comment on Selection")
.help("Comment on Selection")
.disabled(appState.document == nil)
.disabled(appState.document == nil || !appState.hasTextSelection)
}
ToolbarItemGroup {
@@ -286,8 +305,8 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.disabled(appState.document == nil)
.help("Save PDF")
.disabled(!appState.canSaveDocument)
.help(appState.saveHelpText)
Button {
appState.shareDocument()

View File

@@ -6,6 +6,7 @@ import SwiftUI
final class AcademicPDFView: PDFView {
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
var onCancelPlacement: (() -> Void)?
var onSelectionComment: (() -> Void)?
var onPreviousPageKey: (() -> Void)?
var onNextPageKey: (() -> Void)?
@@ -91,6 +92,11 @@ final class AcademicPDFView: PDFView {
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53, placementTool != nil {
onCancelPlacement?()
return
}
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
super.keyDown(with: event)
@@ -156,24 +162,15 @@ final class AcademicPDFView: PDFView {
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
if let direct = page.annotation(at: point),
let editable = editableParent(for: direct, on: page) {
let editable = editableParent(for: direct, on: page),
isInteractionPoint(point, on: direct, editable: editable) {
return editable
}
for annotation in page.annotations.reversed() {
guard let editable = editableParent(for: annotation, on: page) else { continue }
if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) {
return editable
}
if let popup = editable.popup,
popup.bounds.insetBy(dx: -10, dy: -10).contains(point) {
return editable
}
if isTextMarkup(editable),
textMarkupInteractionBounds(for: editable, on: page).contains(point) {
if isInteractionPoint(point, on: annotation, editable: editable) {
return editable
}
}
@@ -181,6 +178,31 @@ final class AcademicPDFView: PDFView {
return nil
}
private func isInteractionPoint(
_ point: CGPoint,
on annotation: PDFAnnotation,
editable: PDFAnnotation
) -> Bool {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
return annotation.bounds.insetBy(dx: -10, dy: -10).contains(point)
}
if isTextMarkup(editable) {
return AnnotationHitTesting.containsTextMarkupPoint(point, in: editable)
}
if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) {
return true
}
if let popup = editable.popup,
popup.bounds.insetBy(dx: -10, dy: -10).contains(point) {
return true
}
return false
}
private func editableParent(for annotation: PDFAnnotation, on page: PDFPage) -> PDFAnnotation? {
if let owner = popupOwner(for: annotation, on: page) {
return isEditableAcademicAnnotation(owner) ? owner : nil
@@ -228,20 +250,6 @@ final class AcademicPDFView: PDFView {
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
}
private func textMarkupInteractionBounds(
for annotation: PDFAnnotation,
on page: PDFPage
) -> CGRect {
var bounds = annotation.bounds.insetBy(dx: -48, dy: -48)
if let popup = annotation.popup {
bounds = bounds.union(popup.bounds.insetBy(dx: -16, dy: -16))
}
let pageBounds = page.bounds(for: displayBox).insetBy(dx: -64, dy: -64)
return bounds.intersection(pageBounds)
}
private func closeNativePopups(on page: PDFPage) {
for annotation in page.annotations {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
@@ -253,8 +261,16 @@ final class AcademicPDFView: PDFView {
}
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) {
let isSelectionComment = annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String
== AnnotationKeys.appKindComment
let hasCommentText = !AnnotationKeys.commentText(for: annotation)
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
return isSelectionComment || hasCommentText
}
return AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|| AnnotationKeys.annotation(annotation, hasSubtype: .text)
|| AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
}
@@ -279,6 +295,11 @@ struct PDFKitRepresentedView: NSViewRepresentable {
appState.placePendingAnnotation(on: page, near: point)
}
}
view.onCancelPlacement = {
Task { @MainActor in
appState.cancelPlacementTool()
}
}
view.onSelectionComment = {
Task { @MainActor in
appState.addComment()
@@ -364,6 +385,45 @@ struct PDFKitRepresentedView: NSViewRepresentable {
of: view,
preferredEdge: preferredEdge(for: anchor, in: view)
)
focusCommentEditor(in: controller.view)
}
private func focusCommentEditor(in view: NSView) {
Self.focusFirstTextView(in: view)
DispatchQueue.main.async { [weak view] in
guard let view else { return }
Self.focusFirstTextView(in: view)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak view] in
guard let view else { return }
Self.focusFirstTextView(in: view)
}
}
private static func focusFirstTextView(in view: NSView) {
view.layoutSubtreeIfNeeded()
guard let textView = firstTextView(in: view) else { return }
textView.window?.makeFirstResponder(textView)
textView.setSelectedRange(NSRange(location: textView.string.utf16.count, length: 0))
textView.insertionPointColor = .labelColor
textView.needsDisplay = true
}
private static func firstTextView(in view: NSView) -> NSTextView? {
if let textView = view as? NSTextView {
return textView
}
for subview in view.subviews {
if let textView = firstTextView(in: subview) {
return textView
}
}
return nil
}
private func dismissCurrent(commit: Bool) {

View File

@@ -1,21 +1,33 @@
import AppKit
import IHatePDFsCore
import SwiftUI
extension View {
func commitOnPlainReturn(_ action: @escaping () -> Void) -> some View {
modifier(ReturnKeyCommitMonitor(action: action))
func commitOnPlainReturn(isEnabled: Bool = true, _ action: @escaping () -> Void) -> some View {
modifier(ReturnKeyCommitMonitor(isEnabled: isEnabled, action: action))
}
}
private struct ReturnKeyCommitMonitor: ViewModifier {
let isEnabled: Bool
let action: () -> Void
@State private var monitor: Any?
@State private var eventWindowBox = EventWindowBox()
func body(content: Content) -> some View {
content
.background(
EventWindowReader { window in
eventWindowBox.windowID = window.map(ObjectIdentifier.init)
}
)
.onAppear {
eventWindowBox.isEnabled = isEnabled
installMonitor()
}
.onChange(of: isEnabled) { value in
eventWindowBox.isEnabled = value
}
.onDisappear {
removeMonitor()
}
@@ -23,8 +35,15 @@ private struct ReturnKeyCommitMonitor: ViewModifier {
private func installMonitor() {
removeMonitor()
let eventWindowBox = eventWindowBox
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
guard isPlainReturn(event) else { return event }
guard eventWindowBox.isEnabled,
shouldCommit(event),
eventWindowBox.windowID.map({ event.window.map(ObjectIdentifier.init) == $0 }) == true
else {
return event
}
action()
return nil
}
@@ -36,10 +55,52 @@ private struct ReturnKeyCommitMonitor: ViewModifier {
self.monitor = nil
}
private func isPlainReturn(_ event: NSEvent) -> Bool {
guard event.keyCode == 36 || event.keyCode == 76 else { return false }
let multilineModifiers: NSEvent.ModifierFlags = [.shift, .option, .command, .control]
return event.modifierFlags.intersection(multilineModifiers).isEmpty
private func shouldCommit(_ event: NSEvent) -> Bool {
let textView = event.window?.firstResponder as? NSTextView
let isEditableMultilineText = textView?.isEditable == true && textView?.isFieldEditor == false
return ReturnKeyCommitPolicy.shouldCommit(
keyCode: UInt16(event.keyCode),
shift: event.modifierFlags.contains(.shift),
option: event.modifierFlags.contains(.option),
command: event.modifierFlags.contains(.command),
control: event.modifierFlags.contains(.control),
isEditableMultilineText: isEditableMultilineText
)
}
}
private final class EventWindowBox {
var windowID: ObjectIdentifier?
var isEnabled = true
}
private struct EventWindowReader: NSViewRepresentable {
let onWindowChange: (NSWindow?) -> Void
func makeNSView(context: Context) -> WindowReportingView {
let view = WindowReportingView()
view.onWindowChange = onWindowChange
return view
}
func updateNSView(_ view: WindowReportingView, context: Context) {
view.onWindowChange = onWindowChange
view.reportWindow()
}
}
private final class WindowReportingView: NSView {
var onWindowChange: ((NSWindow?) -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
reportWindow()
}
func reportWindow() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
onWindowChange?(window)
}
}
}

View File

@@ -98,6 +98,7 @@ struct CommentsReviewSidebar: View {
@State private var showsSearch = false
@State private var showsFilters = false
@State private var showsAdvancedFilters = false
@FocusState private var isCommentSearchFocused: Bool
private var groupedComments: [(pageIndex: Int, items: [AnnotationSnapshot])] {
let grouped = Dictionary(grouping: appState.topLevelComments, by: \.pageIndex)
@@ -106,6 +107,27 @@ struct CommentsReviewSidebar: View {
.sorted { $0.pageIndex < $1.pageIndex }
}
private var visibleCommentCount: Int {
appState.topLevelComments.reduce(0) { partial, item in
partial + 1 + (appState.repliesByParent[item.id]?.count ?? 0)
}
}
private var isFilteringComments: Bool {
hasActiveCommentSearch || hasActiveCommentFilters
}
private var hasActiveCommentSearch: Bool {
!appState.commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var hasActiveCommentFilters: Bool {
appState.commentFilter != .all
|| appState.selectedKindFilter != nil
|| appState.selectedAuthorFilter != "All Authors"
|| appState.selectedStatusFilter != ReviewState.allStatuses
}
var body: some View {
VStack(spacing: 0) {
header
@@ -132,7 +154,7 @@ struct CommentsReviewSidebar: View {
.font(.headline)
.lineLimit(1)
Text("\(appState.annotations.count)")
Text("\(visibleCommentCount)")
.font(.headline.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -141,18 +163,31 @@ struct CommentsReviewSidebar: View {
Button {
showsSearch.toggle()
if showsSearch {
focusCommentSearch()
} else {
isCommentSearchFocused = false
}
} label: {
Label("Search Comments", systemImage: showsSearch ? "magnifyingglass.circle.fill" : "magnifyingglass")
Label(
"Search Comments",
systemImage: (showsSearch || hasActiveCommentSearch) ? "magnifyingglass.circle.fill" : "magnifyingglass"
)
}
.labelStyle(.iconOnly)
.foregroundStyle(hasActiveCommentSearch ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
.help("Search Comments")
Button {
showsFilters.toggle()
} label: {
Label("Filter Comments", systemImage: showsFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
Label(
"Filter Comments",
systemImage: (showsFilters || hasActiveCommentFilters) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"
)
}
.labelStyle(.iconOnly)
.foregroundStyle(hasActiveCommentFilters ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
.help("Filter Comments")
}
.padding(.horizontal, 10)
@@ -184,6 +219,7 @@ struct CommentsReviewSidebar: View {
}
}
.buttonStyle(.plain)
.disabled(!appState.hasTextSelection)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.help("Select text, then add a comment")
@@ -194,6 +230,10 @@ struct CommentsReviewSidebar: View {
if showsSearch {
TextField("Search comments", text: $appState.commentSearchText)
.textFieldStyle(.roundedBorder)
.focused($isCommentSearchFocused)
.onAppear {
focusCommentSearch()
}
}
if showsFilters {
@@ -238,23 +278,75 @@ struct CommentsReviewSidebar: View {
.padding(10)
}
private func focusCommentSearch() {
DispatchQueue.main.async {
isCommentSearchFocused = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isCommentSearchFocused = true
}
}
private var commentList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(groupedComments, id: \.pageIndex) { group in
PageCommentGroup(
pageIndex: group.pageIndex,
items: group.items,
repliesByParent: appState.repliesByParent,
showsPageHeader: appState.pageCount > 1
)
Group {
if groupedComments.isEmpty {
CommentsEmptyState(isFiltering: isFilteringComments)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(groupedComments, id: \.pageIndex) { group in
PageCommentGroup(
pageIndex: group.pageIndex,
items: group.items,
repliesByParent: appState.repliesByParent,
showsPageHeader: appState.pageCount > 1,
isFiltering: isFilteringComments
)
}
}
.padding(.vertical, 4)
}
}
.padding(.vertical, 4)
}
}
}
private struct CommentsEmptyState: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let isFiltering: Bool
var body: some View {
VStack(spacing: 9) {
Image(systemName: isFiltering ? "line.3.horizontal.decrease.circle" : "text.bubble")
.font(.system(size: 28, weight: .regular))
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
Text(isFiltering ? "No matching comments" : "No comments yet")
.font(.callout.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
Text(isFiltering ? "Adjust the search or filters to show more comments." : "Select text in the PDF, then add a comment.")
.font(.caption)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if isFiltering {
Button {
appState.clearCommentFilters()
} label: {
Label("Clear Filters", systemImage: "xmark.circle")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(18)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct PageCommentGroup: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@@ -262,40 +354,37 @@ private struct PageCommentGroup: View {
let items: [AnnotationSnapshot]
let repliesByParent: [String: [AnnotationSnapshot]]
let showsPageHeader: Bool
let isFiltering: Bool
private var isCollapsed: Bool {
showsPageHeader && appState.collapsedPageIndexes.contains(pageIndex)
showsPageHeader && !isFiltering && appState.collapsedPageIndexes.contains(pageIndex)
}
private var visibleItemCount: Int {
items.reduce(0) { partial, item in
partial + 1 + (repliesByParent[item.id]?.count ?? 0)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if showsPageHeader {
Button {
if isCollapsed {
appState.collapsedPageIndexes.remove(pageIndex)
} else {
appState.collapsedPageIndexes.insert(pageIndex)
if isFiltering {
pageHeader
.help("Filtered results are expanded")
} else {
Button {
if isCollapsed {
appState.collapsedPageIndexes.remove(pageIndex)
} else {
appState.collapsedPageIndexes.insert(pageIndex)
}
} label: {
pageHeader
}
} label: {
HStack {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
.font(.caption2.weight(.semibold))
.frame(width: 12)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Text("Page \(pageIndex + 1)")
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Spacer()
Text("\(items.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
}
.padding(.horizontal, 10)
.padding(.top, 7)
.padding(.bottom, 5)
.buttonStyle(.plain)
.help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments")
}
.buttonStyle(.plain)
.help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments")
}
if !isCollapsed {
@@ -307,6 +396,25 @@ private struct PageCommentGroup: View {
}
}
}
private var pageHeader: some View {
HStack {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
.font(.caption2.weight(.semibold))
.frame(width: 12)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Text("Page \(pageIndex + 1)")
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Spacer()
Text("\(visibleItemCount)")
.font(.caption.monospacedDigit())
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
}
.padding(.horizontal, 10)
.padding(.top, 7)
.padding(.bottom, 5)
}
}
private struct CommentRow: View {