Prepare v0.4 release and open source docs
This commit is contained in:
@@ -11,6 +11,19 @@ enum AppSettings {
|
||||
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))
|
||||
@@ -61,6 +74,10 @@ enum AppSettings {
|
||||
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)))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,6 @@ final class CommentPopoverModel: ObservableObject {
|
||||
struct CommentEditorView: View {
|
||||
@ObservedObject var model: CommentPopoverModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@FocusState private var isCommentFocused: Bool
|
||||
private let editorHorizontalInset: CGFloat = 9
|
||||
private let editorVerticalInset: CGFloat = 7
|
||||
|
||||
@@ -58,20 +57,12 @@ struct CommentEditorView: View {
|
||||
.padding(12)
|
||||
.frame(width: 340)
|
||||
.background(.regularMaterial)
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
isCommentFocused = true
|
||||
}
|
||||
}
|
||||
.onChange(of: model.text) { _ in
|
||||
model.updateDraft()
|
||||
}
|
||||
.onChange(of: model.author) { _ in
|
||||
model.updateDraft()
|
||||
}
|
||||
.commitOnPlainReturn {
|
||||
model.commit()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -83,32 +74,23 @@ struct CommentEditorView: View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.commit()
|
||||
} label: {
|
||||
Label("Done", systemImage: "checkmark")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
.help("Done")
|
||||
}
|
||||
}
|
||||
|
||||
private var commentField: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: $model.text)
|
||||
.font(.body)
|
||||
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||
.scrollContentBackground(.hidden)
|
||||
.focused($isCommentFocused)
|
||||
CommitTextView(
|
||||
text: $model.text,
|
||||
font: NSFont.preferredFont(forTextStyle: .body),
|
||||
onCommit: {
|
||||
model.commit()
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, editorHorizontalInset)
|
||||
.padding(.vertical, editorVerticalInset)
|
||||
|
||||
if model.text.isEmpty {
|
||||
Text("Add comment")
|
||||
Text(placeholderText)
|
||||
.font(.body)
|
||||
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
|
||||
.padding(.leading, editorHorizontalInset + 7)
|
||||
@@ -130,6 +112,9 @@ struct CommentEditorView: View {
|
||||
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))
|
||||
@@ -143,7 +128,8 @@ struct CommentEditorView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if !model.context.isNewAnnotation,
|
||||
if model.context.allowsReply,
|
||||
!model.context.isNewAnnotation,
|
||||
model.context.primaryAnnotation != nil {
|
||||
Button {
|
||||
model.reply()
|
||||
@@ -157,14 +143,26 @@ struct CommentEditorView: View {
|
||||
}
|
||||
|
||||
if model.context.allowsDelete {
|
||||
Button(role: .destructive) {
|
||||
model.delete()
|
||||
} label: {
|
||||
Label("Delete Annotation", systemImage: "trash")
|
||||
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")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 34)
|
||||
.help("Delete Annotation")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +171,10 @@ struct CommentEditorView: View {
|
||||
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"
|
||||
|
||||
106
Sources/IHatePDFs/CommitTextView.swift
Normal file
106
Sources/IHatePDFs/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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import IHatePDFsCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@@ -22,6 +23,10 @@ struct IHatePDFsApp: App {
|
||||
|
||||
@MainActor
|
||||
private final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
AppStateRegistry.shared.confirmApplicationShouldTerminate()
|
||||
? .terminateNow
|
||||
@@ -68,6 +73,21 @@ private final class AppStateRegistry {
|
||||
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 }
|
||||
}
|
||||
@@ -91,6 +111,9 @@ private struct AppWindowRoot: View {
|
||||
.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)
|
||||
@@ -144,6 +167,7 @@ private struct WindowCloseGuard: NSViewRepresentable {
|
||||
|
||||
self.window = window
|
||||
previousDelegate = window?.delegate
|
||||
appState?.hostingWindow = window
|
||||
|
||||
if window?.delegate !== self {
|
||||
window?.delegate = self
|
||||
@@ -185,6 +209,7 @@ private struct WindowCloseGuard: NSViewRepresentable {
|
||||
if window?.delegate === self {
|
||||
window?.delegate = previousDelegate
|
||||
}
|
||||
appState?.hostingWindow = nil
|
||||
window = nil
|
||||
previousDelegate = nil
|
||||
}
|
||||
@@ -226,10 +251,18 @@ private struct AppCommands: Commands {
|
||||
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."
|
||||
}
|
||||
@@ -242,6 +275,27 @@ private struct AppCommands: Commands {
|
||||
.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()
|
||||
}
|
||||
@@ -266,14 +320,19 @@ private struct AppCommands: Commands {
|
||||
Button("Settings...") {
|
||||
openSettingsWindow()
|
||||
}
|
||||
.keyboardShortcut(",")
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Close PDF") {
|
||||
appState?.closeDocument()
|
||||
Button(hasDocument ? "Close PDF" : "Close Window") {
|
||||
if hasDocument {
|
||||
appState?.closeDocument()
|
||||
} else {
|
||||
NSApp.keyWindow?.performClose(nil)
|
||||
}
|
||||
}
|
||||
.keyboardShortcut("w")
|
||||
.disabled(!hasDocument)
|
||||
.disabled(appState == nil)
|
||||
}
|
||||
|
||||
CommandGroup(after: .textEditing) {
|
||||
@@ -296,15 +355,21 @@ private struct AppCommands: Commands {
|
||||
.disabled(appState?.searchResults.isEmpty != false)
|
||||
}
|
||||
|
||||
CommandMenu("View") {
|
||||
CommandGroup(after: .toolbar) {
|
||||
Button("Toggle Page Sidebar") {
|
||||
appState?.showLeftSidebar.toggle()
|
||||
appState?.togglePageSidebar()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: [.command, .option])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Toggle Comments Sidebar") {
|
||||
appState?.showCommentsSidebar.toggle()
|
||||
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)
|
||||
@@ -343,11 +408,11 @@ private struct AppCommands: Commands {
|
||||
}
|
||||
|
||||
CommandMenu("Annotate") {
|
||||
Button("Highlight Selection") {
|
||||
appState?.addHighlight()
|
||||
Button(isHighlighterModeActive ? "Turn Highlighter Off" : "Turn Highlighter On") {
|
||||
appState?.toggleHighlighterMode()
|
||||
}
|
||||
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument || !hasTextSelection)
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Underline Selection") {
|
||||
appState?.addUnderline()
|
||||
@@ -368,6 +433,20 @@ private struct AppCommands: Commands {
|
||||
.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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,10 @@ final class AcademicPDFView: PDFView {
|
||||
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? {
|
||||
@@ -16,10 +20,25 @@ final class AcademicPDFView: PDFView {
|
||||
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)
|
||||
@@ -27,7 +46,6 @@ final class AcademicPDFView: PDFView {
|
||||
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
|
||||
@@ -65,6 +83,14 @@ final class AcademicPDFView: PDFView {
|
||||
|
||||
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 }
|
||||
@@ -97,6 +123,24 @@ final class AcademicPDFView: PDFView {
|
||||
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)
|
||||
@@ -116,15 +160,182 @@ final class AcademicPDFView: PDFView {
|
||||
override func resetCursorRects() {
|
||||
super.resetCursorRects()
|
||||
|
||||
if placementTool != nil {
|
||||
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,
|
||||
@@ -305,6 +516,26 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
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()
|
||||
@@ -324,16 +555,27 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
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)
|
||||
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(
|
||||
@@ -343,19 +585,21 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
) {
|
||||
self.appState = appState
|
||||
|
||||
guard let context else {
|
||||
if !isClosing {
|
||||
dismissCurrent(commit: false)
|
||||
if let context {
|
||||
if popoverKind == .comment,
|
||||
editorID == context.id,
|
||||
popover?.isShown == true {
|
||||
return
|
||||
}
|
||||
|
||||
dismissCurrent(commit: true)
|
||||
show(context, in: view, appState: appState)
|
||||
return
|
||||
}
|
||||
|
||||
if editorID == context.id, popover?.isShown == true {
|
||||
return
|
||||
if !isClosing {
|
||||
dismissCurrent(commit: false)
|
||||
}
|
||||
|
||||
dismissCurrent(commit: true)
|
||||
show(context, in: view, appState: appState)
|
||||
}
|
||||
|
||||
private func show(
|
||||
@@ -377,7 +621,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
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(
|
||||
@@ -435,6 +681,7 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
if commit {
|
||||
model?.commit()
|
||||
}
|
||||
commitsCommentOnClose = commit
|
||||
|
||||
if popover.isShown {
|
||||
popover.performClose(nil)
|
||||
@@ -445,15 +692,19 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
|
||||
func popoverWillClose(_ notification: Notification) {
|
||||
isClosing = true
|
||||
model?.commit()
|
||||
if popoverKind == .comment, commitsCommentOnClose {
|
||||
model?.commit()
|
||||
}
|
||||
}
|
||||
|
||||
func popoverDidClose(_ notification: Notification) {
|
||||
let closedEditorID = editorID
|
||||
let closedPopoverKind = popoverKind
|
||||
let currentAppState = appState
|
||||
cleanup()
|
||||
|
||||
if currentAppState?.activeEditor?.id == closedEditorID {
|
||||
if closedPopoverKind == .comment,
|
||||
currentAppState?.activeEditor?.id == closedEditorID {
|
||||
currentAppState?.activeEditor = nil
|
||||
}
|
||||
}
|
||||
@@ -463,7 +714,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
popover = nil
|
||||
model = nil
|
||||
editorID = nil
|
||||
popoverKind = nil
|
||||
isClosing = false
|
||||
commitsCommentOnClose = true
|
||||
}
|
||||
|
||||
private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect {
|
||||
|
||||
180
Sources/IHatePDFs/ReaderAdaptiveLayout.swift
Normal file
180
Sources/IHatePDFs/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)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import AppKit
|
||||
import IHatePDFsCore
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func commitOnPlainReturn(isEnabled: Bool = true, _ action: @escaping () -> Void) -> some View {
|
||||
modifier(ReturnKeyCommitMonitor(isEnabled: isEnabled, action: action))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReturnKeyCommitMonitor: ViewModifier {
|
||||
let isEnabled: Bool
|
||||
let action: () -> Void
|
||||
@State private var monitor: Any?
|
||||
@State private var eventWindowBox = EventWindowBox()
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(
|
||||
EventWindowReader { window in
|
||||
eventWindowBox.windowID = window.map(ObjectIdentifier.init)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
eventWindowBox.isEnabled = isEnabled
|
||||
installMonitor()
|
||||
}
|
||||
.onChange(of: isEnabled) { value in
|
||||
eventWindowBox.isEnabled = value
|
||||
}
|
||||
.onDisappear {
|
||||
removeMonitor()
|
||||
}
|
||||
}
|
||||
|
||||
private func installMonitor() {
|
||||
removeMonitor()
|
||||
let eventWindowBox = eventWindowBox
|
||||
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
||||
guard eventWindowBox.isEnabled,
|
||||
shouldCommit(event),
|
||||
eventWindowBox.windowID.map({ event.window.map(ObjectIdentifier.init) == $0 }) == true
|
||||
else {
|
||||
return event
|
||||
}
|
||||
|
||||
action()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMonitor() {
|
||||
guard let monitor else { return }
|
||||
NSEvent.removeMonitor(monitor)
|
||||
self.monitor = nil
|
||||
}
|
||||
|
||||
private func shouldCommit(_ event: NSEvent) -> Bool {
|
||||
let textView = event.window?.firstResponder as? NSTextView
|
||||
let isEditableMultilineText = textView?.isEditable == true && textView?.isFieldEditor == false
|
||||
return ReturnKeyCommitPolicy.shouldCommit(
|
||||
keyCode: UInt16(event.keyCode),
|
||||
shift: event.modifierFlags.contains(.shift),
|
||||
option: event.modifierFlags.contains(.option),
|
||||
command: event.modifierFlags.contains(.command),
|
||||
control: event.modifierFlags.contains(.control),
|
||||
isEditableMultilineText: isEditableMultilineText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class EventWindowBox {
|
||||
var windowID: ObjectIdentifier?
|
||||
var isEnabled = true
|
||||
}
|
||||
|
||||
private struct EventWindowReader: NSViewRepresentable {
|
||||
let onWindowChange: (NSWindow?) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> WindowReportingView {
|
||||
let view = WindowReportingView()
|
||||
view.onWindowChange = onWindowChange
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: WindowReportingView, context: Context) {
|
||||
view.onWindowChange = onWindowChange
|
||||
view.reportWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private final class WindowReportingView: NSView {
|
||||
var onWindowChange: ((NSWindow?) -> Void)?
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
reportWindow()
|
||||
}
|
||||
|
||||
func reportWindow() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
onWindowChange?(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -103,17 +103,21 @@ public enum AnnotationFactory {
|
||||
date: Date = Date()
|
||||
) -> [AnnotationInsertion] {
|
||||
let lineSelections = selection.selectionsByLine()
|
||||
var groups: [(page: PDFPage, rects: [CGRect])] = []
|
||||
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]))
|
||||
groups.append((page: page, rects: [rect], text: lineText.isEmpty ? [] : [lineText]))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +134,12 @@ public enum AnnotationFactory {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
|
||||
public enum AcademicAnnotationKind: String, CaseIterable {
|
||||
case comment
|
||||
case highlight
|
||||
case underline
|
||||
@@ -11,8 +11,6 @@ public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
|
||||
case reply
|
||||
case other
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public init(annotation: PDFAnnotation) {
|
||||
if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment {
|
||||
self = .comment
|
||||
@@ -62,7 +60,7 @@ public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
public struct AnnotationSnapshot: Identifiable {
|
||||
public let id: String
|
||||
public let pageIndex: Int
|
||||
public let pageLabel: String
|
||||
@@ -73,6 +71,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
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
|
||||
@@ -89,6 +88,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
modifiedAt: Date?,
|
||||
status: String,
|
||||
contents: String,
|
||||
highlightText: String,
|
||||
bounds: CGRect,
|
||||
annotation: PDFAnnotation,
|
||||
page: PDFPage,
|
||||
@@ -104,6 +104,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
self.modifiedAt = modifiedAt
|
||||
self.status = status
|
||||
self.contents = contents
|
||||
self.highlightText = highlightText
|
||||
self.bounds = bounds
|
||||
self.annotation = annotation
|
||||
self.page = page
|
||||
@@ -126,23 +127,22 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
!contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
public var isReply: Bool {
|
||||
parentID != nil
|
||||
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 static func == (lhs: AnnotationSnapshot, rhs: AnnotationSnapshot) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
&& lhs.pageIndex == rhs.pageIndex
|
||||
&& lhs.pageLabel == rhs.pageLabel
|
||||
&& lhs.annotationIndex == rhs.annotationIndex
|
||||
&& lhs.kind == rhs.kind
|
||||
&& lhs.author == rhs.author
|
||||
&& lhs.createdAt == rhs.createdAt
|
||||
&& lhs.modifiedAt == rhs.modifiedAt
|
||||
&& lhs.status == rhs.status
|
||||
&& lhs.contents == rhs.contents
|
||||
&& lhs.bounds == rhs.bounds
|
||||
&& lhs.parentID == rhs.parentID
|
||||
public var isReply: Bool {
|
||||
parentID != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ public enum AnnotationKeys {
|
||||
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,
|
||||
@@ -351,6 +352,7 @@ public enum AnnotationReader {
|
||||
|
||||
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(
|
||||
@@ -384,6 +386,7 @@ public enum AnnotationReader {
|
||||
modifiedAt: annotation.modificationDate,
|
||||
status: status,
|
||||
contents: contents,
|
||||
highlightText: highlightText,
|
||||
bounds: annotation.bounds,
|
||||
annotation: annotation,
|
||||
page: page,
|
||||
|
||||
61
Sources/IHatePDFsCore/PDFDocumentBookmarks.swift
Normal file
61
Sources/IHatePDFsCore/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
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Sources/IHatePDFsCore/PDFRecentDocuments.swift
Normal file
82
Sources/IHatePDFsCore/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
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ public enum ReturnKeyCommitPolicy {
|
||||
option: Bool,
|
||||
command: Bool,
|
||||
control: Bool,
|
||||
isEditableMultilineText: 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