Release v0.3
This commit is contained in:
121
Sources/IHatePDFs/AppSettings.swift
Normal file
121
Sources/IHatePDFs/AppSettings.swift
Normal 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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user