Prepare v0.4 release and open source docs

This commit is contained in:
Akshay Kolli
2026-06-29 23:42:39 -07:00
parent 085d7a16dc
commit 504bd2d39a
58 changed files with 5076 additions and 923 deletions

View File

@@ -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

View File

@@ -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"

View 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)
}
}

View File

@@ -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

View File

@@ -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 {

View 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)
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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,

View 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
}
}
}

View 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
}
}

View File

@@ -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
}
}