Clean up repository structure and release docs
This commit is contained in:
138
sources/app/AppSettings.swift
Normal file
138
sources/app/AppSettings.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
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 highlightSwatches: [(name: String, color: NSColor)] {
|
||||
[
|
||||
("Yellow", AcademicAnnotationPalette.highlight),
|
||||
("Green", NSColor(calibratedRed: 0.27, green: 0.78, blue: 0.28, alpha: 0.58)),
|
||||
("Aqua", NSColor(calibratedRed: 0.0, green: 0.70, blue: 0.78, alpha: 0.58)),
|
||||
("Pink", NSColor(calibratedRed: 1.0, green: 0.32, blue: 0.62, alpha: 0.58)),
|
||||
("Orange", NSColor(calibratedRed: 1.0, green: 0.48, blue: 0.08, alpha: 0.58)),
|
||||
("Purple", NSColor(calibratedRed: 0.62, green: 0.36, blue: 0.94, alpha: 0.58)),
|
||||
("Cyan", NSColor(calibratedRed: 0.0, green: 0.78, blue: 0.92, alpha: 0.56)),
|
||||
("Graphite", NSColor(calibratedWhite: 0.28, alpha: 0.50))
|
||||
]
|
||||
}
|
||||
|
||||
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 displayColor(forHighlightColor color: NSColor) -> NSColor {
|
||||
highlightColor(from: storageString(for: color)).withAlphaComponent(1)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
2442
sources/app/AppState.swift
Normal file
2442
sources/app/AppState.swift
Normal file
File diff suppressed because it is too large
Load Diff
186
sources/app/CommentEditorView.swift
Normal file
186
sources/app/CommentEditorView.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
import IHatePDFsCore
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class CommentPopoverModel: ObservableObject {
|
||||
let context: AnnotationEditorContext
|
||||
|
||||
@Published var text: String
|
||||
@Published var author: String
|
||||
|
||||
private weak var appState: AppState?
|
||||
private var didFinish = false
|
||||
|
||||
init(context: AnnotationEditorContext, appState: AppState) {
|
||||
self.context = context
|
||||
self.appState = appState
|
||||
self.text = context.initialText
|
||||
self.author = context.initialAuthor
|
||||
}
|
||||
|
||||
func commit() {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
appState?.saveEditor(context, text: text, author: author)
|
||||
}
|
||||
|
||||
func delete() {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
appState?.deleteAnnotations(in: context)
|
||||
}
|
||||
|
||||
func updateDraft() {
|
||||
guard !didFinish else { return }
|
||||
appState?.updateEditorDraft(context, text: text, author: author)
|
||||
}
|
||||
|
||||
func reply() {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
appState?.replyFromEditor(context, text: text, author: author)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentEditorView: View {
|
||||
@ObservedObject var model: CommentPopoverModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
private let editorHorizontalInset: CGFloat = 9
|
||||
private let editorVerticalInset: CGFloat = 7
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header
|
||||
commentField
|
||||
footer
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 340)
|
||||
.background(.regularMaterial)
|
||||
.onChange(of: model.text) { _ in
|
||||
model.updateDraft()
|
||||
}
|
||||
.onChange(of: model.author) { _ in
|
||||
model.updateDraft()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: symbolName)
|
||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||
.frame(width: 16)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private var commentField: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
CommitTextView(
|
||||
text: $model.text,
|
||||
font: NSFont.preferredFont(forTextStyle: .body),
|
||||
onCommit: {
|
||||
model.commit()
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, editorHorizontalInset)
|
||||
.padding(.vertical, editorVerticalInset)
|
||||
|
||||
if model.text.isEmpty {
|
||||
Text(placeholderText)
|
||||
.font(.body)
|
||||
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
|
||||
.padding(.leading, editorHorizontalInset + 7)
|
||||
.padding(.top, editorVerticalInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 118)
|
||||
.background(InterfacePalette.fieldFill(for: colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Author", text: $model.author)
|
||||
.textFieldStyle(.plain)
|
||||
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||
.onSubmit {
|
||||
model.commit()
|
||||
}
|
||||
.padding(.horizontal, 7)
|
||||
.frame(height: 28)
|
||||
.background(InterfacePalette.fieldFill(for: colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
|
||||
}
|
||||
.frame(width: 190)
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
if model.context.allowsReply,
|
||||
!model.context.isNewAnnotation,
|
||||
model.context.primaryAnnotation != nil {
|
||||
Button {
|
||||
model.reply()
|
||||
} label: {
|
||||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 34)
|
||||
.help("Reply")
|
||||
.accessibilityLabel("Reply")
|
||||
}
|
||||
|
||||
if model.context.allowsDelete {
|
||||
if model.context.isNewAnnotation {
|
||||
Button {
|
||||
model.delete()
|
||||
} label: {
|
||||
Label("Cancel", systemImage: "xmark")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.frame(width: 34)
|
||||
.help("Cancel")
|
||||
} else {
|
||||
Button(role: .destructive) {
|
||||
model.delete()
|
||||
} label: {
|
||||
Label("Delete Annotation", systemImage: "trash")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 34)
|
||||
.help("Delete Annotation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
model.context.title.replacingOccurrences(of: " Comment", with: "")
|
||||
}
|
||||
|
||||
private var placeholderText: String {
|
||||
model.context.allowsReply ? "Add comment" : "Edit text"
|
||||
}
|
||||
|
||||
private var symbolName: String {
|
||||
guard let annotation = model.context.primaryAnnotation else {
|
||||
return "text.bubble"
|
||||
}
|
||||
|
||||
let kind = AcademicAnnotationKind(annotation: annotation)
|
||||
return kind.symbolName
|
||||
}
|
||||
}
|
||||
106
sources/app/CommitTextView.swift
Normal file
106
sources/app/CommitTextView.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import AppKit
|
||||
import IHatePDFsCore
|
||||
import SwiftUI
|
||||
|
||||
struct CommitTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var font: NSFont
|
||||
var focusOnAppear = true
|
||||
var onCommit: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(text: $text)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.autohidesScrollers = true
|
||||
|
||||
let textView = CommitTextNSTextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.string = text
|
||||
textView.font = font
|
||||
textView.textColor = .labelColor
|
||||
textView.drawsBackground = false
|
||||
textView.isRichText = false
|
||||
textView.importsGraphics = false
|
||||
textView.allowsUndo = true
|
||||
textView.isVerticallyResizable = true
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.autoresizingMask = [.width]
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer?.lineFragmentPadding = 0
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
textView.onCommit = onCommit
|
||||
|
||||
scrollView.documentView = textView
|
||||
context.coordinator.textView = textView
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
guard let textView = scrollView.documentView as? CommitTextNSTextView else { return }
|
||||
|
||||
textView.onCommit = onCommit
|
||||
textView.font = font
|
||||
if textView.string != text {
|
||||
textView.string = text
|
||||
}
|
||||
|
||||
guard focusOnAppear,
|
||||
!context.coordinator.didFocus,
|
||||
textView.window != nil
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
context.coordinator.didFocus = true
|
||||
DispatchQueue.main.async { [weak textView] in
|
||||
guard let textView else { return }
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
}
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
@Binding var text: String
|
||||
weak var textView: NSTextView?
|
||||
var didFocus = false
|
||||
|
||||
init(text: Binding<String>) {
|
||||
_text = text
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else { return }
|
||||
text = textView.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class CommitTextNSTextView: NSTextView {
|
||||
var onCommit: (() -> Void)?
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
guard !hasMarkedText() else {
|
||||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
if 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: isEditable && !isFieldEditor
|
||||
) {
|
||||
onCommit?()
|
||||
return
|
||||
}
|
||||
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
470
sources/app/IHatePDFsApp.swift
Normal file
470
sources/app/IHatePDFsApp.swift
Normal file
@@ -0,0 +1,470 @@
|
||||
import AppKit
|
||||
import IHatePDFsCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IHatePDFsApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AppWindowRoot()
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
.commands {
|
||||
AppCommands()
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func closeOtherEmptyWindows(keeping activeAppState: AppState) {
|
||||
prune()
|
||||
|
||||
for appState in appStates.compactMap(\.value) where appState !== activeAppState {
|
||||
guard appState.document == nil,
|
||||
!appState.hasUnsavedWork,
|
||||
let window = appState.hostingWindow
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func prune() {
|
||||
appStates.removeAll { $0.value == nil }
|
||||
}
|
||||
}
|
||||
|
||||
private final class WeakAppState {
|
||||
weak var value: AppState?
|
||||
|
||||
init(_ value: AppState) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppWindowRoot: View {
|
||||
@StateObject private var appState = AppState()
|
||||
|
||||
var body: some View {
|
||||
MainView()
|
||||
.environmentObject(appState)
|
||||
.focusedObject(appState)
|
||||
.background(WindowCloseGuard(appState: appState))
|
||||
.onOpenURL { url in
|
||||
appState.loadDocument(from: url)
|
||||
if appState.documentURL == url {
|
||||
AppStateRegistry.shared.closeOtherEmptyWindows(keeping: appState)
|
||||
}
|
||||
}
|
||||
.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
|
||||
appState?.hostingWindow = window
|
||||
|
||||
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
|
||||
}
|
||||
appState?.hostingWindow = nil
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppCommands: Commands {
|
||||
@FocusedObject private var appState: AppState?
|
||||
|
||||
private var hasDocument: Bool {
|
||||
appState?.document != nil
|
||||
}
|
||||
|
||||
private var hasTextSelection: Bool {
|
||||
appState?.hasTextSelection == true
|
||||
}
|
||||
|
||||
private var isHighlighterModeActive: Bool {
|
||||
appState?.isHighlighterModeActive == true
|
||||
}
|
||||
|
||||
private var canSaveDocument: Bool {
|
||||
appState?.canSaveDocument == true
|
||||
}
|
||||
|
||||
private var recentDocumentURLs: [URL] {
|
||||
appState?.recentDocumentURLs ?? []
|
||||
}
|
||||
|
||||
private var saveHelpText: String {
|
||||
appState?.saveHelpText ?? "Open a PDF before saving."
|
||||
}
|
||||
|
||||
var body: some Commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("Open...") {
|
||||
appState?.openDocument()
|
||||
}
|
||||
.keyboardShortcut("o")
|
||||
.disabled(appState == nil)
|
||||
|
||||
Menu("Open Recent") {
|
||||
if recentDocumentURLs.isEmpty {
|
||||
Button("No Recent PDFs") {}
|
||||
.disabled(true)
|
||||
} else {
|
||||
ForEach(recentDocumentURLs, id: \.self) { url in
|
||||
Button(url.lastPathComponent) {
|
||||
appState?.openRecentDocument(url)
|
||||
}
|
||||
.help(url.path)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Clear Menu") {
|
||||
appState?.clearRecentDocuments()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(appState == nil)
|
||||
|
||||
Button("Save") {
|
||||
appState?.saveDocument()
|
||||
}
|
||||
.keyboardShortcut("s")
|
||||
.disabled(!canSaveDocument)
|
||||
.help(saveHelpText)
|
||||
|
||||
Button("Save As...") {
|
||||
appState?.saveDocumentAs()
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Share...") {
|
||||
appState?.shareDocument()
|
||||
}
|
||||
.keyboardShortcut("e", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Settings...") {
|
||||
openSettingsWindow()
|
||||
}
|
||||
.keyboardShortcut(",")
|
||||
|
||||
Divider()
|
||||
|
||||
Button(hasDocument ? "Close PDF" : "Close Window") {
|
||||
if hasDocument {
|
||||
appState?.closeDocument()
|
||||
} else {
|
||||
NSApp.keyWindow?.performClose(nil)
|
||||
}
|
||||
}
|
||||
.keyboardShortcut("w")
|
||||
.disabled(appState == nil)
|
||||
}
|
||||
|
||||
CommandGroup(after: .textEditing) {
|
||||
Button("Find in PDF") {
|
||||
appState?.showSearch()
|
||||
}
|
||||
.keyboardShortcut("f")
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Find Next") {
|
||||
appState?.nextSearchResult()
|
||||
}
|
||||
.keyboardShortcut("g")
|
||||
.disabled(appState?.searchResults.isEmpty != false)
|
||||
|
||||
Button("Find Previous") {
|
||||
appState?.previousSearchResult()
|
||||
}
|
||||
.keyboardShortcut("g", modifiers: [.command, .shift])
|
||||
.disabled(appState?.searchResults.isEmpty != false)
|
||||
}
|
||||
|
||||
CommandGroup(after: .toolbar) {
|
||||
Button("Toggle Page Sidebar") {
|
||||
appState?.togglePageSidebar()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: [.command, .option])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Toggle Annotation List") {
|
||||
appState?.toggleAnnotationSidebar()
|
||||
}
|
||||
.keyboardShortcut("2", modifiers: [.command, .option])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Toggle Right Sidebar") {
|
||||
appState?.toggleRightSidebarVisibility()
|
||||
}
|
||||
.keyboardShortcut("1", modifiers: [.command, .option])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Zoom In") {
|
||||
appState?.zoomIn()
|
||||
}
|
||||
.keyboardShortcut("+")
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Zoom Out") {
|
||||
appState?.zoomOut()
|
||||
}
|
||||
.keyboardShortcut("-")
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Fit to Width") {
|
||||
appState?.fitWidth()
|
||||
}
|
||||
.keyboardShortcut("9", modifiers: [.command])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Fit to Page") {
|
||||
appState?.fitPage()
|
||||
}
|
||||
.keyboardShortcut("8", modifiers: [.command])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Two Pages Continuous") {
|
||||
appState?.twoPageContinuous()
|
||||
}
|
||||
.keyboardShortcut("7", modifiers: [.command])
|
||||
.disabled(!hasDocument)
|
||||
}
|
||||
|
||||
CommandMenu("Annotate") {
|
||||
Button(isHighlighterModeActive ? "Turn Highlighter Off" : "Turn Highlighter On") {
|
||||
appState?.toggleHighlighterMode()
|
||||
}
|
||||
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Underline Selection") {
|
||||
appState?.addUnderline()
|
||||
}
|
||||
.keyboardShortcut("u", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument || !hasTextSelection)
|
||||
|
||||
Button("Comment on Selection") {
|
||||
appState?.addComment()
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument || !hasTextSelection)
|
||||
|
||||
Button("Add Free Text") {
|
||||
appState?.addFreeText()
|
||||
}
|
||||
.keyboardShortcut("t", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument)
|
||||
}
|
||||
|
||||
CommandMenu("Bookmark") {
|
||||
Button(appState?.bookmarkActionTitle ?? "Add Bookmark") {
|
||||
appState?.toggleBookmarkForCurrentPage()
|
||||
}
|
||||
.keyboardShortcut("b", modifiers: [.command])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Go to Bookmark") {
|
||||
appState?.goToSavedBookmark()
|
||||
}
|
||||
.keyboardShortcut("b", modifiers: [.command, .option])
|
||||
.disabled(appState?.savedBookmark == nil)
|
||||
}
|
||||
|
||||
CommandGroup(after: .windowArrangement) {
|
||||
Button("Minimize") {
|
||||
appState?.minimizeWindow()
|
||||
}
|
||||
.keyboardShortcut("m", modifiers: [.command])
|
||||
.disabled(appState == nil)
|
||||
|
||||
Button("Toggle Full Screen") {
|
||||
appState?.toggleFullScreen()
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: [.command, .control])
|
||||
.disabled(appState == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettingsWindow() {
|
||||
if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) {
|
||||
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
53
sources/app/InterfacePalette.swift
Normal file
53
sources/app/InterfacePalette.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
enum InterfacePalette {
|
||||
static func primaryText(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: .labelColor).opacity(scheme == .dark ? 0.88 : 0.86)
|
||||
}
|
||||
|
||||
static func secondaryText(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: .secondaryLabelColor).opacity(scheme == .dark ? 0.92 : 0.88)
|
||||
}
|
||||
|
||||
static func quietText(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: .tertiaryLabelColor).opacity(scheme == .dark ? 0.92 : 0.9)
|
||||
}
|
||||
|
||||
static func actionText(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: .controlAccentColor).opacity(scheme == .dark ? 0.78 : 0.72)
|
||||
}
|
||||
|
||||
static func subtleFill(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.045 : 0.026))
|
||||
}
|
||||
|
||||
static func fieldFill(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.055 : 0.032))
|
||||
}
|
||||
|
||||
static func hairline(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.12 : 0.095))
|
||||
}
|
||||
|
||||
static func connector(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.14 : 0.11))
|
||||
}
|
||||
|
||||
static func markerFill(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.055 : 0.035))
|
||||
}
|
||||
|
||||
static func markerStroke(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.16 : 0.13))
|
||||
}
|
||||
|
||||
static func selectedRowFill(for scheme: ColorScheme) -> Color {
|
||||
Color(nsColor: .unemphasizedSelectedContentBackgroundColor)
|
||||
.opacity(scheme == .dark ? 0.38 : 0.48)
|
||||
}
|
||||
|
||||
private static func overlayBase(for scheme: ColorScheme) -> NSColor {
|
||||
scheme == .dark ? .white : .black
|
||||
}
|
||||
}
|
||||
861
sources/app/MainView.swift
Normal file
861
sources/app/MainView.swift
Normal file
@@ -0,0 +1,861 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct MainView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@State private var leftSidebarWidth: CGFloat = ReaderAdaptiveLayout(sizeClass: .regular).leftSidebarIdealWidth
|
||||
@State private var rightSidebarWidth: CGFloat = ReaderAdaptiveLayout(sizeClass: .regular).rightSidebarIdealWidth
|
||||
@State private var leftSidebarDragStartWidth: CGFloat?
|
||||
@State private var rightSidebarDragStartWidth: CGFloat?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
content(availableWidth: proxy.size.width)
|
||||
.onAppear {
|
||||
appState.updateWindowWidth(proxy.size.width)
|
||||
}
|
||||
.onChange(of: proxy.size.width) { width in
|
||||
appState.updateWindowWidth(width)
|
||||
}
|
||||
}
|
||||
.navigationTitle(appState.displayTitle)
|
||||
.frame(
|
||||
minWidth: ReaderAdaptiveLayout.minimumWindowWidth,
|
||||
minHeight: ReaderAdaptiveLayout.minimumWindowHeight
|
||||
)
|
||||
.toolbar {
|
||||
ReaderToolbar()
|
||||
}
|
||||
}
|
||||
|
||||
private func content(availableWidth: CGFloat) -> some View {
|
||||
let layout = ReaderAdaptiveLayout(width: availableWidth)
|
||||
let showsRightSidebar = appState.showCommentsSidebar
|
||||
let showsLeftSidebar = appState.showLeftSidebar && (layout.allowsDualSidebars || !showsRightSidebar)
|
||||
let sidebarWidths = layout.resolvedSidebarWidths(
|
||||
availableWidth: availableWidth,
|
||||
requestedLeft: leftSidebarWidth,
|
||||
requestedRight: rightSidebarWidth,
|
||||
showLeft: showsLeftSidebar,
|
||||
showRight: showsRightSidebar
|
||||
)
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
if appState.document == nil {
|
||||
EmptyDocumentView()
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
if showsLeftSidebar {
|
||||
LeftSidebarView()
|
||||
.frame(width: sidebarWidths.left)
|
||||
.transition(
|
||||
.asymmetric(
|
||||
insertion: .move(edge: .leading).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
)
|
||||
)
|
||||
|
||||
SidebarResizeHandle()
|
||||
.gesture(leftSidebarResizeGesture(for: layout))
|
||||
}
|
||||
|
||||
PDFReaderView()
|
||||
.frame(minWidth: layout.documentMinWidth, maxWidth: .infinity)
|
||||
|
||||
if showsRightSidebar {
|
||||
SidebarResizeHandle()
|
||||
.gesture(rightSidebarResizeGesture(for: layout))
|
||||
|
||||
RightSidebarView()
|
||||
.frame(width: sidebarWidths.right)
|
||||
.transition(
|
||||
.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: appState.showLeftSidebar)
|
||||
.animation(.easeInOut(duration: 0.18), value: appState.showCommentsSidebar)
|
||||
.animation(.easeInOut(duration: 0.18), value: appState.readerSizeClass)
|
||||
}
|
||||
}
|
||||
|
||||
StatusBarView()
|
||||
}
|
||||
}
|
||||
|
||||
private func leftSidebarResizeGesture(for layout: ReaderAdaptiveLayout) -> some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if leftSidebarDragStartWidth == nil {
|
||||
leftSidebarDragStartWidth = leftSidebarWidth
|
||||
}
|
||||
let proposedWidth = (leftSidebarDragStartWidth ?? leftSidebarWidth) + value.translation.width
|
||||
leftSidebarWidth = layout.clampedLeftWidth(proposedWidth)
|
||||
}
|
||||
.onEnded { _ in
|
||||
leftSidebarDragStartWidth = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func rightSidebarResizeGesture(for layout: ReaderAdaptiveLayout) -> some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if rightSidebarDragStartWidth == nil {
|
||||
rightSidebarDragStartWidth = rightSidebarWidth
|
||||
}
|
||||
let proposedWidth = (rightSidebarDragStartWidth ?? rightSidebarWidth) - value.translation.width
|
||||
rightSidebarWidth = layout.clampedRightWidth(proposedWidth)
|
||||
}
|
||||
.onEnded { _ in
|
||||
rightSidebarDragStartWidth = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarResizeHandle: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(Color.clear)
|
||||
.frame(width: ReaderAdaptiveLayout.resizeHandleWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
.overlay(alignment: .center) {
|
||||
Capsule()
|
||||
.fill(InterfacePalette.hairline(for: colorScheme).opacity(isHovering ? 0.95 : 0.58))
|
||||
.frame(width: isHovering ? 2 : 1, height: isHovering ? 42 : 30)
|
||||
.animation(.easeInOut(duration: 0.12), value: isHovering)
|
||||
}
|
||||
.background {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor.opacity(isHovering ? 0.05 : 0))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
if hovering {
|
||||
NSCursor.resizeLeftRight.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if isHovering {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.help("Resize Sidebar")
|
||||
}
|
||||
}
|
||||
|
||||
private struct PDFReaderView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@State private var isDropTargeted = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
PDFKitRepresentedView()
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
|
||||
if appState.canShowSelectionActions {
|
||||
SelectionActionBar()
|
||||
.padding(.bottom, 14)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
|
||||
if isDropTargeted {
|
||||
DropTargetOverlay()
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.985)))
|
||||
}
|
||||
}
|
||||
.onDrop(
|
||||
of: [UTType.fileURL.identifier],
|
||||
isTargeted: $isDropTargeted
|
||||
) { providers in
|
||||
appState.openDroppedDocument(from: providers)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.14), value: appState.canShowSelectionActions)
|
||||
.animation(.easeInOut(duration: 0.16), value: isDropTargeted)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SelectionActionBar: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@AppStorage(AppSettings.highlightColorStorageKey)
|
||||
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
|
||||
|
||||
private var activeHighlightDisplayColor: Color {
|
||||
Color(nsColor: AppSettings.displayColor(forHighlightColor: AppSettings.highlightColor(from: storedHighlightColor)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
actionButton(
|
||||
"Highlight (H)",
|
||||
systemImage: "highlighter",
|
||||
foregroundStyle: activeHighlightDisplayColor
|
||||
) {
|
||||
appState.addHighlight()
|
||||
}
|
||||
|
||||
actionButton(
|
||||
"Underline (U)",
|
||||
systemImage: "underline",
|
||||
foregroundStyle: InterfacePalette.primaryText(for: colorScheme)
|
||||
) {
|
||||
appState.addUnderline()
|
||||
}
|
||||
|
||||
actionButton(
|
||||
"Comment (C)",
|
||||
systemImage: "text.bubble",
|
||||
foregroundStyle: InterfacePalette.primaryText(for: colorScheme)
|
||||
) {
|
||||
appState.addComment()
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
.padding(5)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.28 : 0.12), radius: 12, y: 5)
|
||||
}
|
||||
|
||||
private func actionButton(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
foregroundStyle: Color,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(foregroundStyle)
|
||||
.frame(width: 30, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(title)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyDocumentView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var isDropTargeted = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "doc.text.magnifyingglass")
|
||||
.font(.system(size: 46, weight: .regular))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
|
||||
.frame(width: 62, height: 62)
|
||||
|
||||
Text("Open a PDF")
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Button {
|
||||
appState.openDocument()
|
||||
} label: {
|
||||
Label("Open PDF", systemImage: "doc")
|
||||
}
|
||||
.keyboardShortcut("o")
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.controlSize(.large)
|
||||
}
|
||||
|
||||
RecentPDFsView()
|
||||
.frame(maxWidth: 420)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.vertical, 36)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
if isDropTargeted {
|
||||
DropTargetOverlay()
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.985)))
|
||||
}
|
||||
}
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.onDrop(
|
||||
of: [UTType.fileURL.identifier],
|
||||
isTargeted: $isDropTargeted
|
||||
) { providers in
|
||||
appState.openDroppedDocument(from: providers)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.16), value: isDropTargeted)
|
||||
.onAppear {
|
||||
appState.refreshRecentDocuments()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecentPDFsView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
private var recentPDFs: [RecentDocumentItem] {
|
||||
Array(appState.recentDocuments.prefix(5))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !recentPDFs.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Recent")
|
||||
.font(.callout.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
appState.clearRecentDocuments()
|
||||
} label: {
|
||||
Label("Clear Recent PDFs", systemImage: "xmark.circle")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||
.help("Clear Recent PDFs")
|
||||
}
|
||||
|
||||
ForEach(recentPDFs, id: \.id) { url in
|
||||
Button {
|
||||
appState.openRecentDocument(url.url)
|
||||
} label: {
|
||||
RecentPDFRow(item: url)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(url.url.path)
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecentPDFRow: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let item: RecentDocumentItem
|
||||
|
||||
private var detailText: String {
|
||||
let pieces = [item.pageText, item.openedAt.map { Self.relativeDateFormatter.localizedString(for: $0, relativeTo: Date()) }]
|
||||
.compactMap { $0 }
|
||||
|
||||
if pieces.isEmpty {
|
||||
return item.folderName
|
||||
}
|
||||
return pieces.joined(separator: " - ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.callout)
|
||||
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Text(detailText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 46)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(InterfacePalette.subtleFill(for: colorScheme))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private static let relativeDateFormatter: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
formatter.dateTimeStyle = .named
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
private struct DropTargetOverlay: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "tray.and.arrow.down.fill")
|
||||
.font(.system(size: 36, weight: .regular))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text("Drop to Open")
|
||||
.font(.title3.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(nsColor: .windowBackgroundColor).opacity(colorScheme == .dark ? 0.72 : 0.82))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(
|
||||
Color.accentColor.opacity(colorScheme == .dark ? 0.68 : 0.58),
|
||||
style: StrokeStyle(lineWidth: 2, dash: [8, 6])
|
||||
)
|
||||
.padding(18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusBarView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
private var annotationStatusText: String {
|
||||
let count = appState.annotations.count
|
||||
return count == 1 ? "1 annotation" : "\(count) annotations"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if appState.isCompactWindow {
|
||||
compactStatus
|
||||
} else {
|
||||
regularStatus
|
||||
}
|
||||
}
|
||||
|
||||
private var regularStatus: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(appState.statusMessage)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer()
|
||||
|
||||
if appState.document != nil {
|
||||
if appState.hasUnsentSidebarReplyDraft {
|
||||
Text("Reply draft")
|
||||
}
|
||||
Text(annotationStatusText)
|
||||
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 26)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var compactStatus: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(appState.statusMessage)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if appState.document != nil {
|
||||
if appState.hasUnsentSidebarReplyDraft {
|
||||
Image(systemName: "text.bubble")
|
||||
.help("Reply draft")
|
||||
.accessibilityLabel("Reply draft")
|
||||
}
|
||||
|
||||
Text("\(appState.currentPageIndex + 1)/\(max(appState.pageCount, 1))")
|
||||
.font(.caption.monospacedDigit())
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 24)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReaderToolbar: ToolbarContent {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@AppStorage(AppSettings.highlightColorStorageKey)
|
||||
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
|
||||
@State private var showsHighlightPalette = false
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
private var activeHighlightColor: NSColor {
|
||||
AppSettings.highlightColor(from: storedHighlightColor)
|
||||
}
|
||||
|
||||
private var activeHighlightDisplayColor: Color {
|
||||
Color(nsColor: AppSettings.displayColor(forHighlightColor: activeHighlightColor))
|
||||
}
|
||||
|
||||
private var pageNumberWidth: CGFloat {
|
||||
CGFloat(max(2, String(max(appState.pageCount, 1)).count)) * (appState.isCompactWindow ? 8 : 8.4)
|
||||
+ (appState.isCompactWindow ? 16 : 20)
|
||||
}
|
||||
|
||||
private var totalPageNumberWidth: CGFloat {
|
||||
CGFloat(max(1, String(max(appState.pageCount, 1)).count)) * (appState.isCompactWindow ? 7.8 : 8.2)
|
||||
+ (appState.isCompactWindow ? 18 : 21)
|
||||
}
|
||||
|
||||
private var pageSeparatorSpacing: CGFloat {
|
||||
appState.isCompactWindow ? 28 : 46
|
||||
}
|
||||
|
||||
private var pageControlWidth: CGFloat {
|
||||
pageNumberWidth + totalPageNumberWidth + (appState.isCompactWindow ? 46 : 56)
|
||||
}
|
||||
|
||||
private var compactToolbarControlsEnabled: Bool {
|
||||
appState.isCompactWindow
|
||||
}
|
||||
|
||||
private var annotationToolsMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
appState.toggleHighlighterMode()
|
||||
} label: {
|
||||
Label(
|
||||
appState.isHighlighterModeActive
|
||||
? "Turn Highlighter Off (H)"
|
||||
: "Turn Highlighter On (H)",
|
||||
systemImage: "highlighter"
|
||||
)
|
||||
}
|
||||
.disabled(appState.document == nil)
|
||||
|
||||
Button {
|
||||
appState.addUnderline()
|
||||
} label: {
|
||||
Label("Underline Selection (U)", systemImage: "underline")
|
||||
}
|
||||
.disabled(appState.document == nil || !appState.hasTextSelection)
|
||||
|
||||
Button {
|
||||
appState.addComment()
|
||||
} label: {
|
||||
Label("Comment (C)", systemImage: "text.bubble")
|
||||
}
|
||||
.disabled(appState.document == nil || !appState.hasTextSelection)
|
||||
} label: {
|
||||
Image(systemName: "pencil.tip.crop.circle")
|
||||
}
|
||||
.help("Annotation Tools")
|
||||
.disabled(appState.document == nil)
|
||||
}
|
||||
|
||||
private var highlightColorButton: some View {
|
||||
Button {
|
||||
showsHighlightPalette.toggle()
|
||||
} label: {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundStyle(activeHighlightDisplayColor)
|
||||
}
|
||||
.popover(isPresented: $showsHighlightPalette, arrowEdge: .bottom) {
|
||||
HighlightPalettePopover(
|
||||
storedHighlightColor: $storedHighlightColor,
|
||||
onSelect: { color in
|
||||
showsHighlightPalette = false
|
||||
appState.selectHighlightColor(color, applyToSelection: appState.hasTextSelection)
|
||||
}
|
||||
)
|
||||
}
|
||||
.help("Highlight Color")
|
||||
.accessibilityLabel("Highlight Color Palette")
|
||||
.disabled(appState.document == nil)
|
||||
}
|
||||
|
||||
var body: some ToolbarContent {
|
||||
if appState.document == nil {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
appState.openDocument()
|
||||
} label: {
|
||||
Label("Open", systemImage: "doc")
|
||||
}
|
||||
.help("Open PDF")
|
||||
}
|
||||
} else {
|
||||
ToolbarItemGroup(placement: .navigation) {
|
||||
Button {
|
||||
appState.togglePageSidebar()
|
||||
} label: {
|
||||
Image(systemName: "square.grid.2x2")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.document == nil)
|
||||
.help("Toggle Page Thumbnails")
|
||||
|
||||
Button {
|
||||
appState.toggleBookmarkForCurrentPage()
|
||||
} label: {
|
||||
Image(systemName: appState.currentPageBookmark == nil ? "bookmark" : "bookmark.fill")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.document == nil)
|
||||
.help(appState.bookmarkActionHelpText)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 5) {
|
||||
Button {
|
||||
appState.goToPreviousPage()
|
||||
} label: {
|
||||
Image(systemName: "chevron.up")
|
||||
}
|
||||
.disabled(!appState.canGoToPreviousPage)
|
||||
.help("Previous Page")
|
||||
|
||||
TextField("Page", text: $appState.pageText)
|
||||
.textFieldStyle(.plain)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.frame(width: pageNumberWidth, height: compactToolbarControlsEnabled ? 21 : 22)
|
||||
.onSubmit {
|
||||
appState.goToPageFromField()
|
||||
}
|
||||
.disabled(appState.document == nil)
|
||||
|
||||
HStack(spacing: pageSeparatorSpacing) {
|
||||
Text("/")
|
||||
Text("\(max(appState.pageCount, 1))")
|
||||
}
|
||||
.font(.system(size: 13, weight: .regular, design: .rounded).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: totalPageNumberWidth + pageSeparatorSpacing, alignment: .leading)
|
||||
|
||||
Button {
|
||||
appState.goToNextPage()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
}
|
||||
.disabled(!appState.canGoToNextPage)
|
||||
.help("Next Page")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.frame(width: pageControlWidth)
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
if appState.showToolbarSearch {
|
||||
HStack(spacing: 7) {
|
||||
ZStack(alignment: .trailing) {
|
||||
TextField("Search", text: $appState.searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 13))
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, appState.canClearSearchQuery ? 28 : 10)
|
||||
.frame(height: compactToolbarControlsEnabled ? 26 : 28)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color(nsColor: .textBackgroundColor))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(
|
||||
searchFocused ? Color.accentColor : Color(nsColor: .separatorColor),
|
||||
lineWidth: searchFocused ? 2 : 1
|
||||
)
|
||||
}
|
||||
.focused($searchFocused)
|
||||
.onChange(of: appState.toolbarSearchFocusRequest) { _ in
|
||||
searchFocused = true
|
||||
}
|
||||
.onSubmit {
|
||||
appState.runSearch()
|
||||
}
|
||||
.disabled(appState.document == nil)
|
||||
|
||||
if appState.canClearSearchQuery {
|
||||
Button {
|
||||
appState.clearSearchQuery()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.trailing, 9)
|
||||
.disabled(appState.document == nil)
|
||||
.help("Clear Search")
|
||||
.accessibilityLabel("Clear Search")
|
||||
}
|
||||
}
|
||||
.frame(width: compactToolbarControlsEnabled ? 138 : 154, height: compactToolbarControlsEnabled ? 26 : 28)
|
||||
|
||||
if let searchSummaryText = appState.searchSummaryText {
|
||||
Text(searchSummaryText)
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: searchSummaryText == "No match" ? 58 : 34, alignment: .leading)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel(searchSummaryText)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
appState.previousSearchResult()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.searchResults.isEmpty)
|
||||
.help("Previous Search Match")
|
||||
|
||||
Button {
|
||||
appState.nextSearchResult()
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.searchResults.isEmpty)
|
||||
.help("Next Search Match")
|
||||
|
||||
Button {
|
||||
appState.hideSearch()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.document == nil)
|
||||
.help("Close Search")
|
||||
} else {
|
||||
Button {
|
||||
appState.showSearch()
|
||||
} label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.document == nil)
|
||||
.help("Search")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
annotationToolsMenu
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
highlightColorButton
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
appState.fitWidth()
|
||||
} label: {
|
||||
Image(systemName: "arrow.left.and.right")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.document == nil)
|
||||
.help("Fit to Width")
|
||||
|
||||
Button {
|
||||
appState.toggleRightSidebarVisibility()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.right")
|
||||
.foregroundStyle(appState.showCommentsSidebar ? Color.accentColor : Color.primary)
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.document == nil)
|
||||
.help(appState.showCommentsSidebar ? "Hide Right Sidebar" : "Show Right Sidebar")
|
||||
.accessibilityLabel("Toggle Right Sidebar")
|
||||
|
||||
Button {
|
||||
appState.shareDocument()
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(appState.document == nil)
|
||||
.help("Share PDF")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HighlightPalettePopover: View {
|
||||
@Binding var storedHighlightColor: String
|
||||
let onSelect: (NSColor) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "highlighter")
|
||||
.foregroundStyle(Color(nsColor: AppSettings.displayColor(
|
||||
forHighlightColor: AppSettings.highlightColor(from: storedHighlightColor)
|
||||
)))
|
||||
Text("Highlight")
|
||||
.font(.headline)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(AppSettings.highlightSwatches.enumerated()), id: \.offset) { _, swatch in
|
||||
Button {
|
||||
storedHighlightColor = AppSettings.storageString(forHighlightColor: swatch.color)
|
||||
onSelect(swatch.color)
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(nsColor: AppSettings.displayColor(forHighlightColor: swatch.color)))
|
||||
.frame(width: 24, height: 24)
|
||||
.overlay {
|
||||
Circle()
|
||||
.stroke(
|
||||
isSelected(swatch.color) ? Color.accentColor : Color(nsColor: .separatorColor),
|
||||
lineWidth: isSelected(swatch.color) ? 2 : 0.8
|
||||
)
|
||||
}
|
||||
|
||||
if isSelected(swatch.color) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(Color(nsColor: .labelColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(swatch.name)
|
||||
.accessibilityLabel("Highlight \(swatch.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 284)
|
||||
}
|
||||
|
||||
private func isSelected(_ color: NSColor) -> Bool {
|
||||
AppSettings.storageString(forHighlightColor: color) == storedHighlightColor
|
||||
}
|
||||
}
|
||||
768
sources/app/PDFKitRepresentedView.swift
Normal file
768
sources/app/PDFKitRepresentedView.swift
Normal file
@@ -0,0 +1,768 @@
|
||||
import AppKit
|
||||
import IHatePDFsCore
|
||||
import PDFKit
|
||||
import SwiftUI
|
||||
|
||||
final class AcademicPDFView: PDFView {
|
||||
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
|
||||
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
|
||||
var onCancelPlacement: (() -> Void)?
|
||||
var onSelectionComment: (() -> Void)?
|
||||
var onHighlighterSelection: (() -> Void)?
|
||||
var onToggleHighlighterKey: (() -> Void)?
|
||||
var onUnderlineSelectionKey: (() -> Void)?
|
||||
var onCommentSelectionKey: (() -> Void)?
|
||||
var onPreviousPageKey: (() -> Void)?
|
||||
var onNextPageKey: (() -> Void)?
|
||||
var placementTool: AnnotationPlacementTool? {
|
||||
didSet {
|
||||
guard oldValue != placementTool else { return }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
}
|
||||
}
|
||||
var isHighlighterModeActive = false {
|
||||
didSet {
|
||||
guard oldValue != isHighlighterModeActive else { return }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
if mouseIsInside {
|
||||
applyToolCursorIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
private var handledAnnotationMouseDown = false
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
backgroundColor = NSColor.underPageBackgroundColor
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
handledAnnotationMouseDown = false
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
|
||||
if let page = page(for: point, nearest: false) ?? page(for: point, nearest: true) {
|
||||
closeNativePopups(on: page)
|
||||
let pagePoint = convert(point, to: page)
|
||||
if placementTool != nil {
|
||||
onPlacementClick?(page, pagePoint)
|
||||
return
|
||||
}
|
||||
|
||||
if let annotation = editableAnnotation(on: page, at: pagePoint) {
|
||||
handledAnnotationMouseDown = true
|
||||
closeNativePopups(on: page)
|
||||
onAnnotationClick?(annotation, page)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
super.mouseDown(with: event)
|
||||
window?.makeFirstResponder(self)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
let page = self.page(for: point, nearest: false)
|
||||
else {
|
||||
return
|
||||
}
|
||||
self.closeNativePopups(on: page)
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
if handledAnnotationMouseDown {
|
||||
handledAnnotationMouseDown = false
|
||||
return
|
||||
}
|
||||
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let page = page(for: point, nearest: false) ?? page(for: point, nearest: true)
|
||||
let pagePoint = page.map { convert(point, to: $0) }
|
||||
|
||||
super.mouseUp(with: event)
|
||||
|
||||
if isHighlighterModeActive, hasCommentableSelection {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard self?.hasCommentableSelection == true else { return }
|
||||
self?.onHighlighterSelection?()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let page else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let clickedAnnotation = pagePoint.flatMap {
|
||||
self.editableAnnotation(on: page, at: $0)
|
||||
}
|
||||
let target = clickedAnnotation ?? self.openNativePopupOwner(on: page)
|
||||
|
||||
self.closeNativePopups(on: page)
|
||||
if let target {
|
||||
self.onAnnotationClick?(target, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) {
|
||||
guard hasCommentableSelection else {
|
||||
super.rightMouseDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
let menu = commentMenu(from: super.menu(for: event))
|
||||
NSMenu.popUpContextMenu(menu, with: event, for: self)
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if event.keyCode == 53, placementTool != nil {
|
||||
onCancelPlacement?()
|
||||
return
|
||||
}
|
||||
|
||||
if !event.isARepeat,
|
||||
event.modifierFlags.intersection([.command, .control, .option]).isEmpty,
|
||||
let key = event.charactersIgnoringModifiers?.lowercased() {
|
||||
switch key {
|
||||
case "h":
|
||||
onToggleHighlighterKey?()
|
||||
return
|
||||
case "u":
|
||||
onUnderlineSelectionKey?()
|
||||
return
|
||||
case "c":
|
||||
onCommentSelectionKey?()
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
|
||||
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
|
||||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
switch event.keyCode {
|
||||
case 123, 126:
|
||||
onPreviousPageKey?()
|
||||
case 124, 125:
|
||||
onNextPageKey?()
|
||||
default:
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
override func resetCursorRects() {
|
||||
super.resetCursorRects()
|
||||
|
||||
if isHighlighterModeActive {
|
||||
addCursorRect(bounds, cursor: Self.highlighterCursor)
|
||||
} else if placementTool != nil {
|
||||
addCursorRect(bounds, cursor: .crosshair)
|
||||
}
|
||||
}
|
||||
|
||||
override func cursorUpdate(with event: NSEvent) {
|
||||
if applyToolCursorIfNeeded() {
|
||||
return
|
||||
}
|
||||
super.cursorUpdate(with: event)
|
||||
}
|
||||
|
||||
override func mouseMoved(with event: NSEvent) {
|
||||
super.mouseMoved(with: event)
|
||||
applyToolCursorIfNeeded()
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
super.mouseDragged(with: event)
|
||||
applyToolCursorIfNeeded()
|
||||
}
|
||||
|
||||
override func menu(for event: NSEvent) -> NSMenu? {
|
||||
commentMenu(from: super.menu(for: event))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func applyToolCursorIfNeeded() -> Bool {
|
||||
if isHighlighterModeActive {
|
||||
Self.highlighterCursor.set()
|
||||
return true
|
||||
}
|
||||
if placementTool != nil {
|
||||
NSCursor.crosshair.set()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var mouseIsInside: Bool {
|
||||
guard let window else { return false }
|
||||
return bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
|
||||
}
|
||||
|
||||
private static let highlighterCursor: NSCursor = {
|
||||
let size = NSSize(width: 36, height: 36)
|
||||
let image = highResolutionCursorImage(size: size) {
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
NSGraphicsContext.current?.shouldAntialias = true
|
||||
|
||||
let outline = NSColor.black.withAlphaComponent(0.58)
|
||||
let bodyTint = NSColor(red: 1.0, green: 0.86, blue: 0.28, alpha: 0.98)
|
||||
let bodyShade = NSColor(red: 1.0, green: 0.58, blue: 0.12, alpha: 0.98)
|
||||
let nibColor = NSColor(red: 0.16, green: 0.07, blue: 0.02, alpha: 0.96)
|
||||
|
||||
let highlightTrail = NSBezierPath(roundedRect: NSRect(x: 4.5, y: 3.6, width: 17.5, height: 5.2), xRadius: 2.6, yRadius: 2.6)
|
||||
NSColor(red: 1.0, green: 0.93, blue: 0.28, alpha: 0.34).setFill()
|
||||
highlightTrail.fill()
|
||||
|
||||
let shadow = NSShadow()
|
||||
shadow.shadowOffset = NSSize(width: 0.7, height: -1.1)
|
||||
shadow.shadowBlurRadius = 2.4
|
||||
shadow.shadowColor = NSColor.black.withAlphaComponent(0.24)
|
||||
|
||||
let body = NSBezierPath()
|
||||
body.move(to: NSPoint(x: 10.4, y: 9.2))
|
||||
body.line(to: NSPoint(x: 21.9, y: 23.5))
|
||||
body.curve(
|
||||
to: NSPoint(x: 25.3, y: 23.8),
|
||||
controlPoint1: NSPoint(x: 22.8, y: 24.4),
|
||||
controlPoint2: NSPoint(x: 24.2, y: 24.5)
|
||||
)
|
||||
body.line(to: NSPoint(x: 28.8, y: 20.8))
|
||||
body.curve(
|
||||
to: NSPoint(x: 28.1, y: 17.5),
|
||||
controlPoint1: NSPoint(x: 29.7, y: 20.0),
|
||||
controlPoint2: NSPoint(x: 29.3, y: 18.4)
|
||||
)
|
||||
body.line(to: NSPoint(x: 15.2, y: 4.1))
|
||||
body.curve(
|
||||
to: NSPoint(x: 11.7, y: 4.3),
|
||||
controlPoint1: NSPoint(x: 13.9, y: 3.1),
|
||||
controlPoint2: NSPoint(x: 12.4, y: 3.0)
|
||||
)
|
||||
body.line(to: NSPoint(x: 8.7, y: 6.8))
|
||||
body.curve(
|
||||
to: NSPoint(x: 10.4, y: 9.2),
|
||||
controlPoint1: NSPoint(x: 8.9, y: 7.7),
|
||||
controlPoint2: NSPoint(x: 9.5, y: 8.6)
|
||||
)
|
||||
body.close()
|
||||
body.lineJoinStyle = .round
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
shadow.set()
|
||||
NSGradient(colors: [bodyTint, bodyShade])?.draw(in: body, angle: 38)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
outline.setStroke()
|
||||
body.lineWidth = 1.05
|
||||
body.stroke()
|
||||
|
||||
let grip = NSBezierPath()
|
||||
grip.move(to: NSPoint(x: 17.2, y: 10.4))
|
||||
grip.line(to: NSPoint(x: 25.0, y: 18.6))
|
||||
grip.lineWidth = 1.0
|
||||
NSColor(red: 0.58, green: 0.27, blue: 0.02, alpha: 0.22).setStroke()
|
||||
grip.stroke()
|
||||
|
||||
let shine = NSBezierPath()
|
||||
shine.move(to: NSPoint(x: 15.2, y: 9.5))
|
||||
shine.curve(
|
||||
to: NSPoint(x: 25.0, y: 20.7),
|
||||
controlPoint1: NSPoint(x: 18.8, y: 12.6),
|
||||
controlPoint2: NSPoint(x: 22.1, y: 17.8)
|
||||
)
|
||||
shine.lineWidth = 1.05
|
||||
NSColor.white.withAlphaComponent(0.46).setStroke()
|
||||
shine.stroke()
|
||||
|
||||
let nib = NSBezierPath()
|
||||
nib.move(to: NSPoint(x: 5.3, y: 6.0))
|
||||
nib.line(to: NSPoint(x: 10.6, y: 9.2))
|
||||
nib.curve(
|
||||
to: NSPoint(x: 14.4, y: 4.6),
|
||||
controlPoint1: NSPoint(x: 11.8, y: 9.4),
|
||||
controlPoint2: NSPoint(x: 13.6, y: 4.9)
|
||||
)
|
||||
nib.line(to: NSPoint(x: 8.6, y: 3.1))
|
||||
nib.close()
|
||||
nibColor.setFill()
|
||||
nib.fill()
|
||||
outline.setStroke()
|
||||
nib.lineWidth = 0.9
|
||||
nib.stroke()
|
||||
}
|
||||
return NSCursor(image: image, hotSpot: NSPoint(x: 8, y: 29))
|
||||
}()
|
||||
|
||||
private static func highResolutionCursorImage(
|
||||
size: NSSize,
|
||||
scale: CGFloat = 2,
|
||||
draw: () -> Void
|
||||
) -> NSImage {
|
||||
let image = NSImage(size: size)
|
||||
guard let representation = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(size.width * scale),
|
||||
pixelsHigh: Int(size.height * scale),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
),
|
||||
let context = NSGraphicsContext(bitmapImageRep: representation)
|
||||
else {
|
||||
return image
|
||||
}
|
||||
|
||||
representation.size = size
|
||||
let previousContext = NSGraphicsContext.current
|
||||
NSGraphicsContext.current = context
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
context.cgContext.scaleBy(x: scale, y: scale)
|
||||
draw()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
NSGraphicsContext.current = previousContext
|
||||
image.addRepresentation(representation)
|
||||
return image
|
||||
}
|
||||
|
||||
private var hasCommentableSelection: Bool {
|
||||
guard let selection = currentSelection,
|
||||
!selection.pages.isEmpty,
|
||||
selection.string?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func commentMenu(from baseMenu: NSMenu?) -> NSMenu {
|
||||
let menu = baseMenu ?? NSMenu()
|
||||
guard hasCommentableSelection else { return menu }
|
||||
guard !menu.items.contains(where: { $0.action == #selector(commentOnSelectionFromMenu(_:)) }) else {
|
||||
return menu
|
||||
}
|
||||
|
||||
let item = NSMenuItem(
|
||||
title: "Comment",
|
||||
action: #selector(commentOnSelectionFromMenu(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
item.target = self
|
||||
menu.insertItem(item, at: 0)
|
||||
if menu.items.count > 1 {
|
||||
menu.insertItem(.separator(), at: 1)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
@objc private func commentOnSelectionFromMenu(_ sender: Any?) {
|
||||
onSelectionComment?()
|
||||
}
|
||||
|
||||
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
|
||||
if let direct = page.annotation(at: point),
|
||||
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 isInteractionPoint(point, on: annotation, editable: editable) {
|
||||
return editable
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let parent = AnnotationFactory.parentAnnotation(for: annotation)
|
||||
return isEditableAcademicAnnotation(parent) ? parent : nil
|
||||
}
|
||||
|
||||
private func popupOwner(for annotation: PDFAnnotation, on page: PDFPage) -> PDFAnnotation? {
|
||||
guard AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { return nil }
|
||||
|
||||
if let parent = annotation.value(forAnnotationKey: .parent) as? PDFAnnotation {
|
||||
return parent
|
||||
}
|
||||
|
||||
return page.annotations.first { candidate in
|
||||
candidate.popup === annotation
|
||||
}
|
||||
}
|
||||
|
||||
private func openNativePopupOwner(on page: PDFPage) -> PDFAnnotation? {
|
||||
for annotation in page.annotations.reversed() {
|
||||
if annotation.popup?.isOpen == true,
|
||||
isEditableAcademicAnnotation(annotation) {
|
||||
return annotation
|
||||
}
|
||||
|
||||
guard AnnotationKeys.annotation(annotation, hasSubtype: .popup),
|
||||
annotation.isOpen,
|
||||
let owner = popupOwner(for: annotation, on: page),
|
||||
isEditableAcademicAnnotation(owner)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
return owner
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isTextMarkup(_ annotation: PDFAnnotation) -> Bool {
|
||||
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|
||||
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|
||||
}
|
||||
|
||||
private func closeNativePopups(on page: PDFPage) {
|
||||
for annotation in page.annotations {
|
||||
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
|
||||
annotation.isOpen = false
|
||||
}
|
||||
annotation.popup?.isOpen = false
|
||||
}
|
||||
annotationsChanged(on: page)
|
||||
}
|
||||
|
||||
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> AcademicPDFView {
|
||||
let view = AcademicPDFView()
|
||||
view.onAnnotationClick = { annotation, page in
|
||||
Task { @MainActor in
|
||||
appState.openAnnotationFromPDF(annotation, page: page)
|
||||
}
|
||||
}
|
||||
view.onPlacementClick = { page, point in
|
||||
Task { @MainActor in
|
||||
appState.placePendingAnnotation(on: page, near: point)
|
||||
}
|
||||
}
|
||||
view.onCancelPlacement = {
|
||||
Task { @MainActor in
|
||||
appState.cancelPlacementTool()
|
||||
}
|
||||
}
|
||||
view.onSelectionComment = {
|
||||
Task { @MainActor in
|
||||
appState.addComment()
|
||||
}
|
||||
}
|
||||
view.onHighlighterSelection = {
|
||||
Task { @MainActor in
|
||||
appState.addHighlightFromHighlighterMode()
|
||||
}
|
||||
}
|
||||
view.onToggleHighlighterKey = {
|
||||
Task { @MainActor in
|
||||
appState.toggleHighlighterMode()
|
||||
}
|
||||
}
|
||||
view.onUnderlineSelectionKey = {
|
||||
Task { @MainActor in
|
||||
appState.addUnderline()
|
||||
}
|
||||
}
|
||||
view.onCommentSelectionKey = {
|
||||
Task { @MainActor in
|
||||
appState.addComment()
|
||||
}
|
||||
}
|
||||
view.onPreviousPageKey = {
|
||||
Task { @MainActor in
|
||||
appState.goToPreviousPage()
|
||||
}
|
||||
}
|
||||
view.onNextPageKey = {
|
||||
Task { @MainActor in
|
||||
appState.goToNextPage()
|
||||
}
|
||||
}
|
||||
appState.attachPDFView(view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: AcademicPDFView, context: Context) {
|
||||
if view.document !== appState.document {
|
||||
view.document = appState.document
|
||||
}
|
||||
view.placementTool = appState.placementTool
|
||||
view.isHighlighterModeActive = appState.isHighlighterModeActive
|
||||
view.highlightedSelections = appState.searchResults.isEmpty ? nil : appState.searchResults
|
||||
context.coordinator.sync(
|
||||
editor: appState.activeEditor,
|
||||
in: view,
|
||||
appState: appState
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, NSPopoverDelegate {
|
||||
private enum PopoverKind {
|
||||
case comment
|
||||
}
|
||||
|
||||
private var popover: NSPopover?
|
||||
private var model: CommentPopoverModel?
|
||||
private var editorID: UUID?
|
||||
private var popoverKind: PopoverKind?
|
||||
private var isClosing = false
|
||||
private var commitsCommentOnClose = true
|
||||
private weak var appState: AppState?
|
||||
|
||||
func sync(
|
||||
editor context: AnnotationEditorContext?,
|
||||
in view: AcademicPDFView,
|
||||
appState: AppState
|
||||
) {
|
||||
self.appState = appState
|
||||
|
||||
if let context {
|
||||
if popoverKind == .comment,
|
||||
editorID == context.id,
|
||||
popover?.isShown == true {
|
||||
return
|
||||
}
|
||||
|
||||
dismissCurrent(commit: true)
|
||||
show(context, in: view, appState: appState)
|
||||
return
|
||||
}
|
||||
|
||||
if !isClosing {
|
||||
dismissCurrent(commit: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func show(
|
||||
_ context: AnnotationEditorContext,
|
||||
in view: AcademicPDFView,
|
||||
appState: AppState
|
||||
) {
|
||||
guard view.window != nil else { return }
|
||||
|
||||
let model = CommentPopoverModel(context: context, appState: appState)
|
||||
let controller = NSHostingController(rootView: CommentEditorView(model: model))
|
||||
let popover = NSPopover()
|
||||
popover.behavior = .semitransient
|
||||
popover.animates = true
|
||||
popover.contentSize = NSSize(width: 340, height: 258)
|
||||
popover.contentViewController = controller
|
||||
popover.delegate = self
|
||||
|
||||
self.model = model
|
||||
self.popover = popover
|
||||
self.editorID = context.id
|
||||
self.popoverKind = .comment
|
||||
self.isClosing = false
|
||||
self.commitsCommentOnClose = true
|
||||
|
||||
let anchor = anchorRect(for: context, in: view)
|
||||
popover.show(
|
||||
relativeTo: anchor,
|
||||
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) {
|
||||
guard let popover else {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
if commit {
|
||||
model?.commit()
|
||||
}
|
||||
commitsCommentOnClose = commit
|
||||
|
||||
if popover.isShown {
|
||||
popover.performClose(nil)
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
func popoverWillClose(_ notification: Notification) {
|
||||
isClosing = true
|
||||
if popoverKind == .comment, commitsCommentOnClose {
|
||||
model?.commit()
|
||||
}
|
||||
}
|
||||
|
||||
func popoverDidClose(_ notification: Notification) {
|
||||
let closedEditorID = editorID
|
||||
let closedPopoverKind = popoverKind
|
||||
let currentAppState = appState
|
||||
cleanup()
|
||||
|
||||
if closedPopoverKind == .comment,
|
||||
currentAppState?.activeEditor?.id == closedEditorID {
|
||||
currentAppState?.activeEditor = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanup() {
|
||||
popover?.delegate = nil
|
||||
popover = nil
|
||||
model = nil
|
||||
editorID = nil
|
||||
popoverKind = nil
|
||||
isClosing = false
|
||||
commitsCommentOnClose = true
|
||||
}
|
||||
|
||||
private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect {
|
||||
guard let annotation = context.primaryAnnotation,
|
||||
let page = context.primaryPage ?? annotation.page
|
||||
else {
|
||||
return centeredAnchor(in: view)
|
||||
}
|
||||
|
||||
let rect = view.convert(annotation.bounds, from: page).insetBy(dx: -4, dy: -4)
|
||||
guard rect.width.isFinite,
|
||||
rect.height.isFinite,
|
||||
rect.width > 0,
|
||||
rect.height > 0
|
||||
else {
|
||||
return centeredAnchor(in: view)
|
||||
}
|
||||
|
||||
return rect.intersection(view.bounds).isNull ? centeredAnchor(in: view) : rect
|
||||
}
|
||||
|
||||
private func centeredAnchor(in view: AcademicPDFView) -> NSRect {
|
||||
NSRect(x: view.bounds.midX - 1, y: view.bounds.midY - 1, width: 2, height: 2)
|
||||
}
|
||||
|
||||
private func preferredEdge(for anchor: NSRect, in view: AcademicPDFView) -> NSRectEdge {
|
||||
anchor.midX > view.bounds.midX ? .minX : .maxX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PDFThumbnailRepresentedView: NSViewRepresentable {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
func makeNSView(context: Context) -> PDFThumbnailView {
|
||||
let view = PDFThumbnailView()
|
||||
view.thumbnailSize = CGSize(width: 88, height: 116)
|
||||
view.backgroundColor = .clear
|
||||
view.maximumNumberOfColumns = 1
|
||||
view.labelFont = NSFont.systemFont(ofSize: 11)
|
||||
view.allowsDragging = false
|
||||
view.pdfView = appState.pdfView
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: PDFThumbnailView, context: Context) {
|
||||
view.pdfView = appState.pdfView
|
||||
}
|
||||
}
|
||||
180
sources/app/ReaderAdaptiveLayout.swift
Normal file
180
sources/app/ReaderAdaptiveLayout.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import CoreGraphics
|
||||
|
||||
struct ReaderAdaptiveLayout: Equatable {
|
||||
enum SizeClass: String, CaseIterable {
|
||||
case compact
|
||||
case regular
|
||||
case wide
|
||||
|
||||
init(width: CGFloat) {
|
||||
if width < 960 {
|
||||
self = .compact
|
||||
} else if width < 1280 {
|
||||
self = .regular
|
||||
} else {
|
||||
self = .wide
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarWidths: Equatable {
|
||||
var left: CGFloat
|
||||
var right: CGFloat
|
||||
}
|
||||
|
||||
static let minimumWindowWidth: CGFloat = 820
|
||||
static let minimumWindowHeight: CGFloat = 620
|
||||
static let resizeHandleWidth: CGFloat = 16
|
||||
|
||||
let sizeClass: SizeClass
|
||||
|
||||
init(width: CGFloat) {
|
||||
sizeClass = SizeClass(width: width)
|
||||
}
|
||||
|
||||
init(sizeClass: SizeClass) {
|
||||
self.sizeClass = sizeClass
|
||||
}
|
||||
|
||||
var usesCompactToolbar: Bool {
|
||||
sizeClass == .compact
|
||||
}
|
||||
|
||||
var allowsDualSidebars: Bool {
|
||||
sizeClass != .compact
|
||||
}
|
||||
|
||||
var leftSidebarMinWidth: CGFloat {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return 208
|
||||
case .regular:
|
||||
return 196
|
||||
case .wide:
|
||||
return 220
|
||||
}
|
||||
}
|
||||
|
||||
var leftSidebarIdealWidth: CGFloat {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return 236
|
||||
case .regular:
|
||||
return 215
|
||||
case .wide:
|
||||
return 248
|
||||
}
|
||||
}
|
||||
|
||||
var leftSidebarMaxWidth: CGFloat {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return 300
|
||||
case .regular:
|
||||
return 280
|
||||
case .wide:
|
||||
return 340
|
||||
}
|
||||
}
|
||||
|
||||
var rightSidebarMinWidth: CGFloat {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return 280
|
||||
case .regular:
|
||||
return 280
|
||||
case .wide:
|
||||
return 300
|
||||
}
|
||||
}
|
||||
|
||||
var rightSidebarIdealWidth: CGFloat {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return 292
|
||||
case .regular:
|
||||
return 300
|
||||
case .wide:
|
||||
return 340
|
||||
}
|
||||
}
|
||||
|
||||
var rightSidebarMaxWidth: CGFloat {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return 340
|
||||
case .regular:
|
||||
return 360
|
||||
case .wide:
|
||||
return 420
|
||||
}
|
||||
}
|
||||
|
||||
var documentMinWidth: CGFloat {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return 320
|
||||
case .regular:
|
||||
return 420
|
||||
case .wide:
|
||||
return 560
|
||||
}
|
||||
}
|
||||
|
||||
func clampedLeftWidth(_ width: CGFloat) -> CGFloat {
|
||||
clamped(width, lower: leftSidebarMinWidth, upper: leftSidebarMaxWidth)
|
||||
}
|
||||
|
||||
func clampedRightWidth(_ width: CGFloat) -> CGFloat {
|
||||
clamped(width, lower: rightSidebarMinWidth, upper: rightSidebarMaxWidth)
|
||||
}
|
||||
|
||||
func resolvedSidebarWidths(
|
||||
availableWidth: CGFloat,
|
||||
requestedLeft: CGFloat,
|
||||
requestedRight: CGFloat,
|
||||
showLeft: Bool,
|
||||
showRight: Bool
|
||||
) -> SidebarWidths {
|
||||
let leftHandle = showLeft ? Self.resizeHandleWidth : 0
|
||||
let rightHandle = showRight ? Self.resizeHandleWidth : 0
|
||||
let maxSidebarTotal = max(0, availableWidth - documentMinWidth - leftHandle - rightHandle)
|
||||
|
||||
var left = showLeft ? clampedLeftWidth(requestedLeft) : 0
|
||||
var right = showRight ? clampedRightWidth(requestedRight) : 0
|
||||
|
||||
guard left + right > maxSidebarTotal else {
|
||||
return SidebarWidths(left: left, right: right)
|
||||
}
|
||||
|
||||
var overflow = left + right - maxSidebarTotal
|
||||
if showRight {
|
||||
let reduction = min(overflow, max(0, right - rightSidebarMinWidth))
|
||||
right -= reduction
|
||||
overflow -= reduction
|
||||
}
|
||||
|
||||
if showLeft, overflow > 0 {
|
||||
let reduction = min(overflow, max(0, left - leftSidebarMinWidth))
|
||||
left -= reduction
|
||||
}
|
||||
|
||||
return SidebarWidths(left: left, right: right)
|
||||
}
|
||||
|
||||
func visibleContentWidth(
|
||||
availableWidth: CGFloat,
|
||||
leftWidth: CGFloat,
|
||||
rightWidth: CGFloat,
|
||||
showLeft: Bool,
|
||||
showRight: Bool
|
||||
) -> CGFloat {
|
||||
let leftHandle = showLeft ? Self.resizeHandleWidth : 0
|
||||
let rightHandle = showRight ? Self.resizeHandleWidth : 0
|
||||
return availableWidth - leftWidth - rightWidth - leftHandle - rightHandle
|
||||
}
|
||||
|
||||
private func clamped(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
|
||||
min(max(value, lower), upper)
|
||||
}
|
||||
}
|
||||
30
sources/app/ReviewState.swift
Normal file
30
sources/app/ReviewState.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
enum ReviewState {
|
||||
static let allStatuses = "All Statuses"
|
||||
static let reviewed = "Reviewed"
|
||||
static let notReviewed = "Not reviewed"
|
||||
|
||||
static func isReviewed(_ status: String) -> Bool {
|
||||
status.localizedCaseInsensitiveCompare("Marked") == .orderedSame
|
||||
|| status.localizedCaseInsensitiveCompare(reviewed) == .orderedSame
|
||||
}
|
||||
|
||||
static func label(for status: String) -> String {
|
||||
if isReviewed(status) { return reviewed }
|
||||
return status.localizedCaseInsensitiveCompare("Unmarked") == .orderedSame
|
||||
? notReviewed
|
||||
: status
|
||||
}
|
||||
|
||||
static func matches(_ status: String, filter: String) -> Bool {
|
||||
switch filter {
|
||||
case allStatuses:
|
||||
return true
|
||||
case reviewed:
|
||||
return isReviewed(status)
|
||||
case notReviewed:
|
||||
return !isReviewed(status)
|
||||
default:
|
||||
return status == filter || label(for: status) == filter
|
||||
}
|
||||
}
|
||||
}
|
||||
1312
sources/app/SidebarViews.swift
Normal file
1312
sources/app/SidebarViews.swift
Normal file
File diff suppressed because it is too large
Load Diff
84
sources/core/AnnotationColorPreference.swift
Normal file
84
sources/core/AnnotationColorPreference.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
public enum AnnotationColorPreference {
|
||||
public static func color(
|
||||
from storageValue: String?,
|
||||
fallback: NSColor,
|
||||
minimumAlpha: CGFloat = 0
|
||||
) -> NSColor {
|
||||
guard let storageValue,
|
||||
let color = color(from: storageValue)
|
||||
else {
|
||||
return normalized(fallback, fallback: fallback, minimumAlpha: minimumAlpha)
|
||||
}
|
||||
|
||||
return normalized(color, fallback: fallback, minimumAlpha: minimumAlpha)
|
||||
}
|
||||
|
||||
public static func storageString(for color: NSColor, fallback: String = "#FFD11F85") -> String {
|
||||
guard let rgb = color.usingColorSpace(.deviceRGB) else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
|
||||
return String(
|
||||
format: "#%02X%02X%02X%02X",
|
||||
byte(red),
|
||||
byte(green),
|
||||
byte(blue),
|
||||
byte(alpha)
|
||||
)
|
||||
}
|
||||
|
||||
private static func color(from storageValue: String) -> NSColor? {
|
||||
var raw = storageValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.hasPrefix("#") {
|
||||
raw.removeFirst()
|
||||
}
|
||||
|
||||
guard raw.count == 8,
|
||||
let value = UInt32(raw, radix: 16)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let red = CGFloat((value >> 24) & 0xFF) / 255
|
||||
let green = CGFloat((value >> 16) & 0xFF) / 255
|
||||
let blue = CGFloat((value >> 8) & 0xFF) / 255
|
||||
let alpha = CGFloat(value & 0xFF) / 255
|
||||
return NSColor(deviceRed: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
|
||||
private static func normalized(
|
||||
_ color: NSColor,
|
||||
fallback: NSColor,
|
||||
minimumAlpha: CGFloat
|
||||
) -> NSColor {
|
||||
guard let rgb = color.usingColorSpace(.deviceRGB) else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
|
||||
return NSColor(
|
||||
deviceRed: red,
|
||||
green: green,
|
||||
blue: blue,
|
||||
alpha: max(alpha, minimumAlpha)
|
||||
)
|
||||
}
|
||||
|
||||
private static func byte(_ value: CGFloat) -> Int {
|
||||
max(0, min(255, Int((value * 255).rounded())))
|
||||
}
|
||||
}
|
||||
560
sources/core/AnnotationFactory.swift
Normal file
560
sources/core/AnnotationFactory.swift
Normal file
@@ -0,0 +1,560 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
public enum AcademicAnnotationPalette {
|
||||
public static let comment = NSColor(
|
||||
calibratedRed: 0.98,
|
||||
green: 0.64,
|
||||
blue: 0.16,
|
||||
alpha: 0.30
|
||||
)
|
||||
public static let highlight = NSColor(
|
||||
calibratedRed: 1.0,
|
||||
green: 0.78,
|
||||
blue: 0.0,
|
||||
alpha: 0.52
|
||||
)
|
||||
public static let underline = NSColor(
|
||||
calibratedRed: 0.48,
|
||||
green: 0.53,
|
||||
blue: 0.62,
|
||||
alpha: 0.56
|
||||
)
|
||||
public static let note = NSColor(
|
||||
calibratedRed: 0.64,
|
||||
green: 0.59,
|
||||
blue: 0.49,
|
||||
alpha: 0.9
|
||||
)
|
||||
public static let reply = NSColor(
|
||||
calibratedRed: 0.52,
|
||||
green: 0.58,
|
||||
blue: 0.60,
|
||||
alpha: 0.88
|
||||
)
|
||||
public static let freeTextFill = NSColor(
|
||||
calibratedRed: 0.91,
|
||||
green: 0.86,
|
||||
blue: 0.75,
|
||||
alpha: 0.32
|
||||
)
|
||||
public static let freeTextInk = NSColor(
|
||||
calibratedWhite: 0.22,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
|
||||
public enum MarkupAnnotationStyle {
|
||||
case comment
|
||||
case highlight
|
||||
case underline
|
||||
|
||||
var subtype: PDFAnnotationSubtype {
|
||||
switch self {
|
||||
case .comment: return .highlight
|
||||
case .highlight: return .highlight
|
||||
case .underline: return .underline
|
||||
}
|
||||
}
|
||||
|
||||
func color(
|
||||
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
|
||||
commentColor: NSColor = AcademicAnnotationPalette.comment
|
||||
) -> NSColor {
|
||||
switch self {
|
||||
case .comment: return commentColor
|
||||
case .highlight: return highlightColor
|
||||
case .underline: return AcademicAnnotationPalette.underline
|
||||
}
|
||||
}
|
||||
|
||||
var markupType: PDFMarkupType {
|
||||
switch self {
|
||||
case .comment: return .highlight
|
||||
case .highlight: return .highlight
|
||||
case .underline: return .underline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AnnotationInsertion {
|
||||
public let page: PDFPage
|
||||
public let annotation: PDFAnnotation
|
||||
public let popup: PDFAnnotation?
|
||||
|
||||
public init(page: PDFPage, annotation: PDFAnnotation, popup: PDFAnnotation?) {
|
||||
self.page = page
|
||||
self.annotation = annotation
|
||||
self.popup = popup
|
||||
}
|
||||
}
|
||||
|
||||
public enum AnnotationFactory {
|
||||
public static let defaultAuthor = NSFullUserName().isEmpty ? NSUserName() : NSFullUserName()
|
||||
|
||||
public static func markupInsertions(
|
||||
from selection: PDFSelection,
|
||||
style: MarkupAnnotationStyle,
|
||||
comment: String,
|
||||
author: String,
|
||||
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
|
||||
commentColor: NSColor = AcademicAnnotationPalette.comment,
|
||||
date: Date = Date()
|
||||
) -> [AnnotationInsertion] {
|
||||
let lineSelections = selection.selectionsByLine()
|
||||
var groups: [(page: PDFPage, rects: [CGRect], text: [String])] = []
|
||||
|
||||
for lineSelection in lineSelections {
|
||||
let lineText = lineSelection.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
for page in lineSelection.pages {
|
||||
let rect = lineSelection.bounds(for: page).insetBy(dx: -1.5, dy: -1.0)
|
||||
guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue }
|
||||
|
||||
if let index = groups.firstIndex(where: { $0.page === page }) {
|
||||
groups[index].rects.append(rect)
|
||||
if !lineText.isEmpty {
|
||||
groups[index].text.append(lineText)
|
||||
}
|
||||
} else {
|
||||
groups.append((page: page, rects: [rect], text: lineText.isEmpty ? [] : [lineText]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups.compactMap { group in
|
||||
guard let firstRect = group.rects.first else { return nil }
|
||||
let unionRect = group.rects.dropFirst().reduce(firstRect) { partial, rect in
|
||||
partial.union(rect)
|
||||
}
|
||||
let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil)
|
||||
annotation.markupType = style.markupType
|
||||
annotation.color = style.color(highlightColor: highlightColor, commentColor: commentColor)
|
||||
annotation.quadrilateralPoints = group.rects.flatMap { rect in
|
||||
quadPoints(for: rect, relativeTo: unionRect)
|
||||
}
|
||||
standardize(annotation, comment: comment, author: author, date: date)
|
||||
if style == .highlight {
|
||||
let highlightText = group.text.joined(separator: " ")
|
||||
if !highlightText.isEmpty {
|
||||
_ = annotation.setValue(highlightText, forAnnotationKey: AnnotationKeys.appHighlightText)
|
||||
}
|
||||
}
|
||||
if style == .comment {
|
||||
_ = annotation.setValue(AnnotationKeys.appKindComment, forAnnotationKey: AnnotationKeys.appKind)
|
||||
}
|
||||
let popup = makePopupIfNeeded(for: annotation, on: group.page, open: false)
|
||||
return AnnotationInsertion(page: group.page, annotation: annotation, popup: popup)
|
||||
}
|
||||
}
|
||||
|
||||
public static func noteInsertion(
|
||||
on page: PDFPage,
|
||||
near point: CGPoint,
|
||||
comment: String,
|
||||
author: String,
|
||||
date: Date = Date()
|
||||
) -> AnnotationInsertion {
|
||||
let bounds = clampedRect(
|
||||
desired: CGRect(x: point.x, y: point.y, width: 28, height: 28),
|
||||
on: page,
|
||||
fallbackSize: CGSize(width: 28, height: 28)
|
||||
)
|
||||
let annotation = PDFAnnotation(bounds: bounds, forType: .text, withProperties: nil)
|
||||
annotation.color = AcademicAnnotationPalette.note
|
||||
annotation.iconType = .note
|
||||
standardize(annotation, comment: comment, author: author, date: date)
|
||||
let popup = makePopupIfNeeded(for: annotation, on: page, open: false)
|
||||
return AnnotationInsertion(page: page, annotation: annotation, popup: popup)
|
||||
}
|
||||
|
||||
public static func freeTextInsertion(
|
||||
on page: PDFPage,
|
||||
near point: CGPoint,
|
||||
text: String,
|
||||
author: String,
|
||||
date: Date = Date()
|
||||
) -> AnnotationInsertion {
|
||||
let bounds = clampedRect(
|
||||
desired: CGRect(x: point.x - 120, y: point.y - 40, width: 240, height: 80),
|
||||
on: page,
|
||||
fallbackSize: CGSize(width: 240, height: 80)
|
||||
)
|
||||
let annotation = PDFAnnotation(bounds: bounds, forType: .freeText, withProperties: nil)
|
||||
annotation.font = NSFont.systemFont(ofSize: 13)
|
||||
annotation.fontColor = AcademicAnnotationPalette.freeTextInk
|
||||
annotation.alignment = .left
|
||||
annotation.color = AcademicAnnotationPalette.freeTextFill
|
||||
|
||||
let border = PDFBorder()
|
||||
border.lineWidth = 0.75
|
||||
annotation.border = border
|
||||
|
||||
standardize(annotation, comment: text, author: author, date: date)
|
||||
return AnnotationInsertion(page: page, annotation: annotation, popup: nil)
|
||||
}
|
||||
|
||||
public static func replyInsertion(
|
||||
to parent: PDFAnnotation,
|
||||
on page: PDFPage,
|
||||
comment: String,
|
||||
author: String,
|
||||
parentID: String? = nil,
|
||||
date: Date = Date()
|
||||
) -> AnnotationInsertion {
|
||||
let parentBounds = parent.bounds
|
||||
let targetPoint = CGPoint(
|
||||
x: parentBounds.maxX + 16,
|
||||
y: max(parentBounds.minY, parentBounds.midY - 12)
|
||||
)
|
||||
let bounds = clampedRect(
|
||||
desired: CGRect(origin: targetPoint, size: CGSize(width: 24, height: 24)),
|
||||
on: page,
|
||||
fallbackSize: CGSize(width: 24, height: 24)
|
||||
)
|
||||
let annotation = PDFAnnotation(bounds: bounds, forType: .text, withProperties: nil)
|
||||
annotation.color = AcademicAnnotationPalette.reply
|
||||
annotation.iconType = .comment
|
||||
standardize(annotation, comment: comment, author: author, date: date)
|
||||
let parentIdentifier = parentID
|
||||
?? parent.value(forAnnotationKey: .name) as? String
|
||||
?? UUID().uuidString
|
||||
_ = annotation.setValue(parentIdentifier, forAnnotationKey: AnnotationKeys.inReplyTo)
|
||||
_ = annotation.setValue("R", forAnnotationKey: AnnotationKeys.replyType)
|
||||
annotation.shouldDisplay = false
|
||||
annotation.shouldPrint = false
|
||||
return AnnotationInsertion(page: page, annotation: annotation, popup: nil)
|
||||
}
|
||||
|
||||
public static func updateComment(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage,
|
||||
text: String,
|
||||
author: String,
|
||||
date: Date = Date()
|
||||
) -> PDFAnnotation? {
|
||||
AnnotationKeys.setCommentText(text, for: annotation)
|
||||
annotation.contents = text
|
||||
annotation.userName = author
|
||||
annotation.modificationDate = date
|
||||
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
|
||||
_ = annotation.setValue(date, forAnnotationKey: .date)
|
||||
if annotation.value(forAnnotationKey: AnnotationKeys.creationDate) == nil {
|
||||
_ = annotation.setValue(
|
||||
AnnotationKeys.pdfDateString(from: date),
|
||||
forAnnotationKey: AnnotationKeys.creationDate
|
||||
)
|
||||
}
|
||||
|
||||
if AnnotationKeys.annotation(annotation, hasSubtype: .freeText) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let popup = annotation.popup {
|
||||
page.removeAnnotation(popup)
|
||||
annotation.popup = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if AnnotationKeys.isReply(annotation) {
|
||||
hideReplyMarker(annotation, on: page)
|
||||
return nil
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
popup.contents = text
|
||||
popup.userName = author
|
||||
popup.modificationDate = date
|
||||
popup.isOpen = false
|
||||
return nil
|
||||
}
|
||||
|
||||
return makePopupIfNeeded(for: annotation, on: page, open: false)
|
||||
}
|
||||
|
||||
public static func standardize(
|
||||
_ annotation: PDFAnnotation,
|
||||
comment: String,
|
||||
author: String,
|
||||
date: Date
|
||||
) {
|
||||
AnnotationKeys.setCommentText(comment, for: annotation)
|
||||
annotation.contents = comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? nil
|
||||
: comment
|
||||
annotation.userName = author
|
||||
annotation.modificationDate = date
|
||||
annotation.shouldDisplay = true
|
||||
annotation.shouldPrint = true
|
||||
_ = annotation.setValue(UUID().uuidString, forAnnotationKey: .name)
|
||||
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
|
||||
_ = annotation.setValue(date, forAnnotationKey: .date)
|
||||
_ = annotation.setValue(
|
||||
AnnotationKeys.pdfDateString(from: date),
|
||||
forAnnotationKey: AnnotationKeys.creationDate
|
||||
)
|
||||
_ = annotation.setValue("Unmarked", forAnnotationKey: AnnotationKeys.state)
|
||||
_ = annotation.setValue("Marked", forAnnotationKey: AnnotationKeys.stateModel)
|
||||
}
|
||||
|
||||
public static func makePopupIfNeeded(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage,
|
||||
open: Bool
|
||||
) -> PDFAnnotation? {
|
||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { return nil }
|
||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .freeText) else { return nil }
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
guard !contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
popup.contents = contents
|
||||
popup.userName = annotation.userName
|
||||
popup.modificationDate = annotation.modificationDate
|
||||
popup.isOpen = open
|
||||
popup.bounds = popupRect(for: annotation.bounds, on: page)
|
||||
popup.shouldDisplay = true
|
||||
popup.shouldPrint = true
|
||||
return popup.page == nil ? popup : nil
|
||||
}
|
||||
|
||||
let popupBounds = popupRect(for: annotation.bounds, on: page)
|
||||
let popup = PDFAnnotation(bounds: popupBounds, forType: .popup, withProperties: nil)
|
||||
popup.contents = contents
|
||||
popup.userName = annotation.userName
|
||||
popup.modificationDate = annotation.modificationDate
|
||||
popup.isOpen = open
|
||||
popup.shouldDisplay = true
|
||||
popup.shouldPrint = true
|
||||
annotation.popup = popup
|
||||
return popup
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func normalizePopupPlacement(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage
|
||||
) -> Bool {
|
||||
guard let popup = annotation.popup else { return false }
|
||||
|
||||
let bounds = popupRect(for: annotation.bounds, on: page)
|
||||
guard popup.bounds != bounds else { return false }
|
||||
popup.bounds = bounds
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func setPopupMarkerVisibility(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage,
|
||||
isVisible: Bool
|
||||
) -> Bool {
|
||||
guard let popup = annotation.popup else { return false }
|
||||
|
||||
let oldBounds = popup.bounds
|
||||
let oldShouldDisplay = popup.shouldDisplay
|
||||
let oldShouldPrint = popup.shouldPrint
|
||||
let oldIsOpen = popup.isOpen
|
||||
|
||||
popup.bounds = popupRect(for: annotation.bounds, on: page)
|
||||
popup.shouldDisplay = isVisible
|
||||
popup.shouldPrint = isVisible
|
||||
popup.isOpen = false
|
||||
|
||||
return oldBounds != popup.bounds
|
||||
|| oldShouldDisplay != popup.shouldDisplay
|
||||
|| oldShouldPrint != popup.shouldPrint
|
||||
|| oldIsOpen != popup.isOpen
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func restoreCommentTextForExport(_ annotation: PDFAnnotation) -> Bool {
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
return restoreCommentText(contents, forExportIn: annotation)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func prepareForPreviewCompatibleExport(
|
||||
_ annotation: PDFAnnotation,
|
||||
on page: PDFPage
|
||||
) -> Bool {
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
var didChange = restoreCommentText(contents, forExportIn: annotation)
|
||||
|
||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .freeText) else {
|
||||
return didChange
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
if popup.page != nil {
|
||||
page.removeAnnotation(popup)
|
||||
}
|
||||
annotation.popup = nil
|
||||
didChange = true
|
||||
}
|
||||
|
||||
let linkedPopups = page.annotations.filter { candidate in
|
||||
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
|
||||
return parentAnnotation(for: candidate) === annotation
|
||||
}
|
||||
|
||||
for popup in linkedPopups {
|
||||
page.removeAnnotation(popup)
|
||||
didChange = true
|
||||
}
|
||||
|
||||
if restoreCommentText(contents, forExportIn: annotation) {
|
||||
didChange = true
|
||||
}
|
||||
|
||||
return didChange
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func restoreCommentText(
|
||||
_ contents: String,
|
||||
forExportIn annotation: PDFAnnotation
|
||||
) -> Bool {
|
||||
let exportedContents = contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? nil
|
||||
: contents
|
||||
let oldContents = annotation.contents
|
||||
|
||||
annotation.contents = exportedContents
|
||||
if !contents.isEmpty {
|
||||
AnnotationKeys.setCommentText(contents, for: annotation)
|
||||
}
|
||||
|
||||
return oldContents != annotation.contents
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func detachPopupForViewer(
|
||||
from annotation: PDFAnnotation,
|
||||
on page: PDFPage
|
||||
) -> Bool {
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
let userName = annotation.userName
|
||||
let modificationDate = annotation.modificationDate
|
||||
let creationDate = annotation.value(forAnnotationKey: AnnotationKeys.creationDate)
|
||||
let textLabel = annotation.value(forAnnotationKey: .textLabel)
|
||||
let date = annotation.value(forAnnotationKey: .date)
|
||||
let shouldSuppressNativeContents = !AnnotationKeys.isReply(annotation)
|
||||
&& !AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
|
||||
let oldContents = annotation.contents
|
||||
var didChange = false
|
||||
|
||||
if !contents.isEmpty || annotation.value(forAnnotationKey: AnnotationKeys.appCommentText) == nil {
|
||||
AnnotationKeys.setCommentText(contents, for: annotation)
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
popup.isOpen = false
|
||||
popup.shouldDisplay = false
|
||||
popup.shouldPrint = false
|
||||
if popup.page != nil {
|
||||
page.removeAnnotation(popup)
|
||||
}
|
||||
annotation.popup = nil
|
||||
didChange = true
|
||||
}
|
||||
|
||||
annotation.contents = shouldSuppressNativeContents ? nil : contents
|
||||
annotation.userName = userName
|
||||
annotation.modificationDate = modificationDate
|
||||
if let creationDate {
|
||||
_ = annotation.setValue(creationDate, forAnnotationKey: AnnotationKeys.creationDate)
|
||||
}
|
||||
if let textLabel {
|
||||
_ = annotation.setValue(textLabel, forAnnotationKey: .textLabel)
|
||||
}
|
||||
if let date {
|
||||
_ = annotation.setValue(date, forAnnotationKey: .date)
|
||||
}
|
||||
|
||||
return didChange || oldContents != annotation.contents
|
||||
}
|
||||
|
||||
public static func hideReplyMarker(_ annotation: PDFAnnotation, on page: PDFPage) {
|
||||
guard AnnotationKeys.isReply(annotation) else { return }
|
||||
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
let userName = annotation.userName
|
||||
let modificationDate = annotation.modificationDate
|
||||
|
||||
if let popup = annotation.popup {
|
||||
page.removeAnnotation(popup)
|
||||
annotation.popup = nil
|
||||
}
|
||||
|
||||
let pageBounds = page.bounds(for: .cropBox)
|
||||
annotation.bounds = CGRect(
|
||||
x: pageBounds.maxX + 32,
|
||||
y: pageBounds.maxY + 32,
|
||||
width: 24,
|
||||
height: 24
|
||||
)
|
||||
annotation.shouldDisplay = false
|
||||
annotation.shouldPrint = false
|
||||
AnnotationKeys.setCommentText(contents, for: annotation)
|
||||
annotation.contents = contents
|
||||
annotation.userName = userName
|
||||
annotation.modificationDate = modificationDate
|
||||
}
|
||||
|
||||
public static func parentAnnotation(for annotation: PDFAnnotation) -> PDFAnnotation {
|
||||
if AnnotationKeys.annotation(annotation, hasSubtype: .popup),
|
||||
let parent = annotation.value(forAnnotationKey: .parent) as? PDFAnnotation {
|
||||
return parent
|
||||
}
|
||||
return annotation
|
||||
}
|
||||
|
||||
private static func quadPoints(for rect: CGRect, relativeTo bounds: CGRect) -> [NSValue] {
|
||||
let minX = rect.minX - bounds.minX
|
||||
let maxX = rect.maxX - bounds.minX
|
||||
let minY = rect.minY - bounds.minY
|
||||
let maxY = rect.maxY - bounds.minY
|
||||
|
||||
return [
|
||||
NSValue(point: CGPoint(x: minX, y: maxY)),
|
||||
NSValue(point: CGPoint(x: maxX, y: maxY)),
|
||||
NSValue(point: CGPoint(x: minX, y: minY)),
|
||||
NSValue(point: CGPoint(x: maxX, y: minY))
|
||||
]
|
||||
}
|
||||
|
||||
private static func popupRect(for annotationBounds: CGRect, on page: PDFPage) -> CGRect {
|
||||
let pageBounds = page.bounds(for: .cropBox)
|
||||
let indicatorInset: CGFloat = 28
|
||||
let verticalInset: CGFloat = 12
|
||||
let y = min(
|
||||
max(annotationBounds.maxY - indicatorInset, pageBounds.minY + verticalInset),
|
||||
pageBounds.maxY - indicatorInset - verticalInset
|
||||
)
|
||||
|
||||
return CGRect(
|
||||
x: pageBounds.maxX - indicatorInset,
|
||||
y: y,
|
||||
width: 240,
|
||||
height: 120
|
||||
)
|
||||
}
|
||||
|
||||
private static func clampedRect(
|
||||
desired: CGRect,
|
||||
on page: PDFPage,
|
||||
fallbackSize: CGSize
|
||||
) -> CGRect {
|
||||
let pageBounds = page.bounds(for: .cropBox).insetBy(dx: 12, dy: 12)
|
||||
let width = min(desired.width > 0 ? desired.width : fallbackSize.width, pageBounds.width)
|
||||
let height = min(desired.height > 0 ? desired.height : fallbackSize.height, pageBounds.height)
|
||||
let x = min(max(desired.minX, pageBounds.minX), pageBounds.maxX - width)
|
||||
let y = min(max(desired.minY, pageBounds.minY), pageBounds.maxY - height)
|
||||
return CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
}
|
||||
46
sources/core/AnnotationHitTesting.swift
Normal file
46
sources/core/AnnotationHitTesting.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
public enum AnnotationHitTesting {
|
||||
public static func containsTextMarkupPoint(
|
||||
_ point: CGPoint,
|
||||
in annotation: PDFAnnotation,
|
||||
tolerance: CGFloat = 3
|
||||
) -> Bool {
|
||||
guard AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|
||||
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|
||||
else {
|
||||
return annotation.bounds.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
|
||||
}
|
||||
|
||||
let quadPoints = annotation.quadrilateralPoints ?? []
|
||||
guard !quadPoints.isEmpty else {
|
||||
return annotation.bounds.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
|
||||
}
|
||||
|
||||
var index = 0
|
||||
while index + 3 < quadPoints.count {
|
||||
let points = quadPoints[index..<(index + 4)].map { value in
|
||||
let relativePoint = value.pointValue
|
||||
return CGPoint(
|
||||
x: annotation.bounds.minX + relativePoint.x,
|
||||
y: annotation.bounds.minY + relativePoint.y
|
||||
)
|
||||
}
|
||||
if boundingRect(for: points).insetBy(dx: -tolerance, dy: -tolerance).contains(point) {
|
||||
return true
|
||||
}
|
||||
index += 4
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static func boundingRect(for points: [CGPoint]) -> CGRect {
|
||||
guard let first = points.first else { return .null }
|
||||
|
||||
return points.dropFirst().reduce(CGRect(origin: first, size: .zero)) { rect, point in
|
||||
rect.union(CGRect(origin: point, size: .zero))
|
||||
}
|
||||
}
|
||||
}
|
||||
451
sources/core/AnnotationModels.swift
Normal file
451
sources/core/AnnotationModels.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
public enum AcademicAnnotationKind: String, CaseIterable {
|
||||
case comment
|
||||
case highlight
|
||||
case underline
|
||||
case note
|
||||
case freeText
|
||||
case reply
|
||||
case other
|
||||
|
||||
public init(annotation: PDFAnnotation) {
|
||||
if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment {
|
||||
self = .comment
|
||||
return
|
||||
}
|
||||
|
||||
if AnnotationKeys.isReply(annotation) {
|
||||
self = .reply
|
||||
return
|
||||
}
|
||||
|
||||
if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) {
|
||||
self = .highlight
|
||||
} else if AnnotationKeys.annotation(annotation, hasSubtype: .underline) {
|
||||
self = .underline
|
||||
} else if AnnotationKeys.annotation(annotation, hasSubtype: .text) {
|
||||
self = .note
|
||||
} else if AnnotationKeys.annotation(annotation, hasSubtype: .freeText) {
|
||||
self = .freeText
|
||||
} else {
|
||||
self = .other
|
||||
}
|
||||
}
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .comment: return "Comment"
|
||||
case .highlight: return "Highlight"
|
||||
case .underline: return "Underline"
|
||||
case .note: return "Note"
|
||||
case .freeText: return "Free Text"
|
||||
case .reply: return "Reply"
|
||||
case .other: return "Other"
|
||||
}
|
||||
}
|
||||
|
||||
public var symbolName: String {
|
||||
switch self {
|
||||
case .comment: return "text.bubble"
|
||||
case .highlight: return "highlighter"
|
||||
case .underline: return "underline"
|
||||
case .note: return "note.text"
|
||||
case .freeText: return "textformat"
|
||||
case .reply: return "arrowshape.turn.up.left"
|
||||
case .other: return "ellipsis"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AnnotationSnapshot: Identifiable {
|
||||
public let id: String
|
||||
public let pageIndex: Int
|
||||
public let pageLabel: String
|
||||
public let annotationIndex: Int
|
||||
public let kind: AcademicAnnotationKind
|
||||
public let author: String
|
||||
public let createdAt: Date?
|
||||
public let modifiedAt: Date?
|
||||
public let status: String
|
||||
public let contents: String
|
||||
public let highlightText: String
|
||||
public let bounds: CGRect
|
||||
public let annotation: PDFAnnotation
|
||||
public let page: PDFPage
|
||||
public let parentID: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
pageLabel: String,
|
||||
annotationIndex: Int,
|
||||
kind: AcademicAnnotationKind,
|
||||
author: String,
|
||||
createdAt: Date?,
|
||||
modifiedAt: Date?,
|
||||
status: String,
|
||||
contents: String,
|
||||
highlightText: String,
|
||||
bounds: CGRect,
|
||||
annotation: PDFAnnotation,
|
||||
page: PDFPage,
|
||||
parentID: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.pageIndex = pageIndex
|
||||
self.pageLabel = pageLabel
|
||||
self.annotationIndex = annotationIndex
|
||||
self.kind = kind
|
||||
self.author = author
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt
|
||||
self.status = status
|
||||
self.contents = contents
|
||||
self.highlightText = highlightText
|
||||
self.bounds = bounds
|
||||
self.annotation = annotation
|
||||
self.page = page
|
||||
self.parentID = parentID
|
||||
}
|
||||
|
||||
public var firstLine: String {
|
||||
let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let first = trimmed
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.first
|
||||
.map(String.init)
|
||||
else {
|
||||
return "No comment"
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
public var hasComment: Bool {
|
||||
!contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
public var highlightExcerpt: String {
|
||||
let stored = highlightText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
|
||||
let fallback = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !fallback.isEmpty {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return "Highlight on page \(pageLabel)"
|
||||
}
|
||||
|
||||
public var isReply: Bool {
|
||||
parentID != nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum AnnotationKeys {
|
||||
public static let inReplyTo = PDFAnnotationKey(rawValue: "IRT")
|
||||
public static let replyType = PDFAnnotationKey(rawValue: "RT")
|
||||
public static let creationDate = PDFAnnotationKey(rawValue: "CreationDate")
|
||||
public static let state = PDFAnnotationKey(rawValue: "State")
|
||||
public static let stateModel = PDFAnnotationKey(rawValue: "StateModel")
|
||||
public static let appKind = PDFAnnotationKey(rawValue: "IHatePDFsKind")
|
||||
public static let appKindComment = "Comment"
|
||||
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
|
||||
public static let appHighlightText = PDFAnnotationKey(rawValue: "IHatePDFsHighlightText")
|
||||
|
||||
public static func commentText(for annotation: PDFAnnotation) -> String {
|
||||
if let value = annotation.value(forAnnotationKey: appCommentText) as? String,
|
||||
!value.isEmpty {
|
||||
return value
|
||||
}
|
||||
|
||||
if let contents = annotation.contents, !contents.isEmpty {
|
||||
return contents
|
||||
}
|
||||
|
||||
return annotation.popup?.contents ?? ""
|
||||
}
|
||||
|
||||
public static func setCommentText(_ text: String, for annotation: PDFAnnotation) {
|
||||
_ = annotation.setValue(text, forAnnotationKey: appCommentText)
|
||||
}
|
||||
|
||||
public static func stableID(
|
||||
for annotation: PDFAnnotation,
|
||||
pageIndex: Int,
|
||||
annotationIndex: Int
|
||||
) -> String {
|
||||
if let name = annotation.value(forAnnotationKey: .name) as? String, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
|
||||
let type = annotation.type ?? "Unknown"
|
||||
let rect = annotation.bounds
|
||||
return [
|
||||
"page-\(pageIndex + 1)",
|
||||
"annotation-\(annotationIndex)",
|
||||
type,
|
||||
String(format: "%.2f-%.2f-%.2f-%.2f", rect.minX, rect.minY, rect.width, rect.height)
|
||||
].joined(separator: "-")
|
||||
}
|
||||
|
||||
public static func parentID(
|
||||
for annotation: PDFAnnotation,
|
||||
document: PDFDocument?
|
||||
) -> String? {
|
||||
if let parentID = annotation.value(forAnnotationKey: inReplyTo) as? String,
|
||||
!parentID.isEmpty {
|
||||
return stableIDForAnnotation(named: parentID, in: document) ?? parentID
|
||||
}
|
||||
|
||||
guard let parent = annotation.value(forAnnotationKey: inReplyTo) as? PDFAnnotation else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let page = parent.page,
|
||||
let document,
|
||||
document.index(for: page) != NSNotFound
|
||||
else {
|
||||
return parent.value(forAnnotationKey: .name) as? String
|
||||
}
|
||||
|
||||
let pageIndex = document.index(for: page)
|
||||
let annotationIndex = page.annotations.firstIndex(where: { $0 === parent }) ?? 0
|
||||
return stableID(for: parent, pageIndex: pageIndex, annotationIndex: annotationIndex)
|
||||
}
|
||||
|
||||
private static func stableIDForAnnotation(named name: String, in document: PDFDocument?) -> String? {
|
||||
guard let document else { return nil }
|
||||
|
||||
for pageIndex in 0..<document.pageCount {
|
||||
guard let page = document.page(at: pageIndex) else { continue }
|
||||
for (annotationIndex, candidate) in page.annotations.enumerated() {
|
||||
guard candidate.value(forAnnotationKey: .name) as? String == name else { continue }
|
||||
return stableID(for: candidate, pageIndex: pageIndex, annotationIndex: annotationIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func isReply(_ annotation: PDFAnnotation) -> Bool {
|
||||
annotation.value(forAnnotationKey: inReplyTo) is PDFAnnotation
|
||||
|| annotation.value(forAnnotationKey: inReplyTo) is String
|
||||
}
|
||||
|
||||
public static func annotation(_ annotation: PDFAnnotation, hasSubtype subtype: PDFAnnotationSubtype) -> Bool {
|
||||
guard let type = annotation.type else { return false }
|
||||
let raw = subtype.rawValue
|
||||
let normalized = raw.hasPrefix("/") ? String(raw.dropFirst()) : raw
|
||||
return type == raw || type == normalized
|
||||
}
|
||||
|
||||
public static func pdfDateString(from date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "'D:'yyyyMMddHHmmss'Z00''00'''"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
public static func dateValue(for key: PDFAnnotationKey, in annotation: PDFAnnotation) -> Date? {
|
||||
if let date = annotation.value(forAnnotationKey: key) as? Date {
|
||||
return date
|
||||
}
|
||||
|
||||
guard let value = annotation.value(forAnnotationKey: key) as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return parsePDFDate(value)
|
||||
}
|
||||
|
||||
private static func parsePDFDate(_ value: String) -> Date? {
|
||||
let normalized = value
|
||||
.replacingOccurrences(of: "Z00'00'", with: "Z")
|
||||
.replacingOccurrences(of: "Z00\\'00\\'", with: "Z")
|
||||
let formats = [
|
||||
"'D:'yyyyMMddHHmmss'Z'",
|
||||
"'D:'yyyyMMddHHmmss",
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
]
|
||||
|
||||
for format in formats {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: normalized) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum AnnotationReader {
|
||||
public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] {
|
||||
var result: [AnnotationSnapshot] = []
|
||||
var namedAnnotationIDs: [String: String]?
|
||||
|
||||
for pageIndex in 0..<document.pageCount {
|
||||
guard let page = document.page(at: pageIndex) else { continue }
|
||||
result.append(contentsOf: snapshots(
|
||||
in: document,
|
||||
page: page,
|
||||
pageIndex: pageIndex,
|
||||
namedAnnotationIDs: &namedAnnotationIDs
|
||||
))
|
||||
}
|
||||
|
||||
return sorted(result)
|
||||
}
|
||||
|
||||
public static func snapshots(in document: PDFDocument, pages: [PDFPage]) -> [AnnotationSnapshot] {
|
||||
var result: [AnnotationSnapshot] = []
|
||||
var seenPageIndexes = Set<Int>()
|
||||
var namedAnnotationIDs: [String: String]?
|
||||
|
||||
for page in pages {
|
||||
let pageIndex = document.index(for: page)
|
||||
guard pageIndex != NSNotFound, seenPageIndexes.insert(pageIndex).inserted else { continue }
|
||||
result.append(contentsOf: snapshots(
|
||||
in: document,
|
||||
page: page,
|
||||
pageIndex: pageIndex,
|
||||
namedAnnotationIDs: &namedAnnotationIDs
|
||||
))
|
||||
}
|
||||
|
||||
return sorted(result)
|
||||
}
|
||||
|
||||
public static func sorted(_ snapshots: [AnnotationSnapshot]) -> [AnnotationSnapshot] {
|
||||
snapshots.sorted { left, right in
|
||||
if left.pageIndex != right.pageIndex {
|
||||
return left.pageIndex < right.pageIndex
|
||||
}
|
||||
if left.bounds.maxY != right.bounds.maxY {
|
||||
return left.bounds.maxY > right.bounds.maxY
|
||||
}
|
||||
return left.bounds.minX < right.bounds.minX
|
||||
}
|
||||
}
|
||||
|
||||
private static func snapshots(
|
||||
in document: PDFDocument,
|
||||
page: PDFPage,
|
||||
pageIndex: Int,
|
||||
namedAnnotationIDs: inout [String: String]?
|
||||
) -> [AnnotationSnapshot] {
|
||||
var result: [AnnotationSnapshot] = []
|
||||
|
||||
for (annotationIndex, annotation) in page.annotations.enumerated() {
|
||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { continue }
|
||||
|
||||
let kind = AcademicAnnotationKind(annotation: annotation)
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
let highlightText = annotation.value(forAnnotationKey: AnnotationKeys.appHighlightText) as? String ?? ""
|
||||
guard kind != .other || !contents.isEmpty else { continue }
|
||||
|
||||
let id = AnnotationKeys.stableID(
|
||||
for: annotation,
|
||||
pageIndex: pageIndex,
|
||||
annotationIndex: annotationIndex
|
||||
)
|
||||
let pageLabel = page.label ?? "\(pageIndex + 1)"
|
||||
let author = annotation.userName
|
||||
?? annotation.value(forAnnotationKey: .textLabel) as? String
|
||||
?? "Unknown"
|
||||
let createdAt = AnnotationKeys.dateValue(for: AnnotationKeys.creationDate, in: annotation)
|
||||
?? annotation.modificationDate
|
||||
let status = annotation.value(forAnnotationKey: AnnotationKeys.state) as? String
|
||||
?? "Unmarked"
|
||||
let parentID = parentID(
|
||||
for: annotation,
|
||||
document: document,
|
||||
namedAnnotationIDs: &namedAnnotationIDs
|
||||
)
|
||||
|
||||
result.append(
|
||||
AnnotationSnapshot(
|
||||
id: id,
|
||||
pageIndex: pageIndex,
|
||||
pageLabel: pageLabel,
|
||||
annotationIndex: annotationIndex,
|
||||
kind: kind,
|
||||
author: author,
|
||||
createdAt: createdAt,
|
||||
modifiedAt: annotation.modificationDate,
|
||||
status: status,
|
||||
contents: contents,
|
||||
highlightText: highlightText,
|
||||
bounds: annotation.bounds,
|
||||
annotation: annotation,
|
||||
page: page,
|
||||
parentID: parentID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func parentID(
|
||||
for annotation: PDFAnnotation,
|
||||
document: PDFDocument,
|
||||
namedAnnotationIDs: inout [String: String]?
|
||||
) -> String? {
|
||||
if let parentID = annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? String,
|
||||
!parentID.isEmpty {
|
||||
if namedAnnotationIDs == nil {
|
||||
namedAnnotationIDs = makeNamedAnnotationIDs(in: document)
|
||||
}
|
||||
return namedAnnotationIDs?[parentID]
|
||||
}
|
||||
|
||||
guard let parent = annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? PDFAnnotation else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let page = parent.page,
|
||||
document.index(for: page) != NSNotFound
|
||||
else {
|
||||
return parent.value(forAnnotationKey: .name) as? String
|
||||
}
|
||||
|
||||
let pageIndex = document.index(for: page)
|
||||
let annotationIndex = page.annotations.firstIndex(where: { $0 === parent }) ?? 0
|
||||
return AnnotationKeys.stableID(for: parent, pageIndex: pageIndex, annotationIndex: annotationIndex)
|
||||
}
|
||||
|
||||
private static func makeNamedAnnotationIDs(in document: PDFDocument) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
|
||||
for pageIndex in 0..<document.pageCount {
|
||||
guard let page = document.page(at: pageIndex) else { continue }
|
||||
for (annotationIndex, annotation) in page.annotations.enumerated() {
|
||||
guard let name = annotation.value(forAnnotationKey: .name) as? String,
|
||||
!name.isEmpty
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
result[name] = AnnotationKeys.stableID(
|
||||
for: annotation,
|
||||
pageIndex: pageIndex,
|
||||
annotationIndex: annotationIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
61
sources/core/PDFDocumentBookmarks.swift
Normal file
61
sources/core/PDFDocumentBookmarks.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
public struct PDFDocumentBookmark {
|
||||
public var id: String
|
||||
public var pageIndex: Int
|
||||
public var pageLabel: String
|
||||
public var title: String
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
pageIndex: Int,
|
||||
pageLabel: String,
|
||||
title: String,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.pageIndex = pageIndex
|
||||
self.pageLabel = pageLabel
|
||||
self.title = title
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public enum PDFDocumentBookmarks {
|
||||
public static func sorted(_ bookmarks: [PDFDocumentBookmark]) -> [PDFDocumentBookmark] {
|
||||
preferredBookmark(in: bookmarks).map { [$0] } ?? []
|
||||
}
|
||||
|
||||
public static func upsert(
|
||||
_ bookmark: PDFDocumentBookmark,
|
||||
in _: [PDFDocumentBookmark]
|
||||
) -> [PDFDocumentBookmark] {
|
||||
[bookmark]
|
||||
}
|
||||
|
||||
public static func removing(id: String, from bookmarks: [PDFDocumentBookmark]) -> [PDFDocumentBookmark] {
|
||||
sorted(bookmarks.filter { $0.id != id })
|
||||
}
|
||||
|
||||
public static func bookmark(on pageIndex: Int, in bookmarks: [PDFDocumentBookmark]) -> PDFDocumentBookmark? {
|
||||
sorted(bookmarks).first { $0.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
public static func clamped(
|
||||
_ bookmarks: [PDFDocumentBookmark],
|
||||
pageCount: Int
|
||||
) -> [PDFDocumentBookmark] {
|
||||
guard pageCount > 0 else { return [] }
|
||||
return sorted(bookmarks.filter { (0..<pageCount).contains($0.pageIndex) })
|
||||
}
|
||||
|
||||
private static func preferredBookmark(in bookmarks: [PDFDocumentBookmark]) -> PDFDocumentBookmark? {
|
||||
bookmarks.max {
|
||||
if $0.createdAt != $1.createdAt {
|
||||
return $0.createdAt < $1.createdAt
|
||||
}
|
||||
return $0.pageIndex < $1.pageIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
19
sources/core/PDFFileSelection.swift
Normal file
19
sources/core/PDFFileSelection.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public enum PDFFileSelection {
|
||||
public static func isPDFFileURL(_ url: URL) -> Bool {
|
||||
guard url.isFileURL else { return false }
|
||||
|
||||
let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey, .isDirectoryKey])
|
||||
if resourceValues?.isDirectory == true {
|
||||
return false
|
||||
}
|
||||
|
||||
if let contentType = resourceValues?.contentType {
|
||||
return contentType.conforms(to: .pdf)
|
||||
}
|
||||
|
||||
return url.pathExtension.localizedCaseInsensitiveCompare("pdf") == .orderedSame
|
||||
}
|
||||
}
|
||||
82
sources/core/PDFRecentDocuments.swift
Normal file
82
sources/core/PDFRecentDocuments.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
public struct PDFRecentDocumentProgress {
|
||||
public var key: String
|
||||
public var pageIndex: Int
|
||||
public var openedAt: Date
|
||||
|
||||
public init(key: String, pageIndex: Int, openedAt: Date) {
|
||||
self.key = key
|
||||
self.pageIndex = pageIndex
|
||||
self.openedAt = openedAt
|
||||
}
|
||||
}
|
||||
|
||||
public enum PDFRecentDocuments {
|
||||
public static func filteredPDFs(
|
||||
from urls: [URL],
|
||||
currentURL: URL? = nil,
|
||||
limit: Int,
|
||||
fileExists: (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) }
|
||||
) -> [URL] {
|
||||
guard limit > 0 else { return [] }
|
||||
|
||||
var result: [URL] = []
|
||||
var seen = Set<URL>()
|
||||
let current = currentURL.map(normalized)
|
||||
|
||||
for url in urls {
|
||||
let normalizedURL = normalized(url)
|
||||
guard normalizedURL != current,
|
||||
seen.insert(normalizedURL).inserted,
|
||||
PDFFileSelection.isPDFFileURL(normalizedURL),
|
||||
fileExists(normalizedURL)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
result.append(normalizedURL)
|
||||
if result.count == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public static func documentKey(for url: URL) -> String {
|
||||
normalized(url).path
|
||||
}
|
||||
|
||||
public static func progress(
|
||||
for url: URL,
|
||||
in records: [String: PDFRecentDocumentProgress]
|
||||
) -> PDFRecentDocumentProgress? {
|
||||
records[documentKey(for: url)]
|
||||
}
|
||||
|
||||
public static func updatedProgress(
|
||||
_ records: [String: PDFRecentDocumentProgress],
|
||||
url: URL,
|
||||
pageIndex: Int,
|
||||
openedAt: Date
|
||||
) -> [String: PDFRecentDocumentProgress] {
|
||||
let key = documentKey(for: url)
|
||||
var copy = records
|
||||
copy[key] = PDFRecentDocumentProgress(
|
||||
key: key,
|
||||
pageIndex: max(0, pageIndex),
|
||||
openedAt: openedAt
|
||||
)
|
||||
return copy
|
||||
}
|
||||
|
||||
public static func clampedPageIndex(_ pageIndex: Int?, pageCount: Int) -> Int {
|
||||
guard pageCount > 0, let pageIndex else { return 0 }
|
||||
return min(max(0, pageIndex), pageCount - 1)
|
||||
}
|
||||
|
||||
static func normalized(_ url: URL) -> URL {
|
||||
url.standardizedFileURL
|
||||
}
|
||||
}
|
||||
20
sources/core/ReturnKeyCommitPolicy.swift
Normal file
20
sources/core/ReturnKeyCommitPolicy.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
public enum ReturnKeyCommitPolicy {
|
||||
public static func shouldCommit(
|
||||
keyCode: UInt16,
|
||||
shift: Bool,
|
||||
option: Bool,
|
||||
command: Bool,
|
||||
control: Bool,
|
||||
isEditableMultilineText: Bool,
|
||||
commandReturnOnly: Bool = false
|
||||
) -> Bool {
|
||||
guard isEditableMultilineText else { return false }
|
||||
guard keyCode == 36 || keyCode == 76 else { return false }
|
||||
if commandReturnOnly {
|
||||
return command && !shift && !option && !control
|
||||
}
|
||||
return !shift && !option && !command && !control
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user