Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
085d7a16dc | ||
|
|
3d112c677a | ||
|
|
c2ca546c8c | ||
|
|
4d030f1d0f | ||
|
|
eae686a45f |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,11 @@
|
|||||||
.build/
|
.build/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
dist/
|
dist/
|
||||||
|
release/
|
||||||
|
*.zip
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
*.xcworkspace/xcuserdata/
|
*.xcworkspace/xcuserdata/
|
||||||
*.xcodeproj/xcuserdata/
|
*.xcodeproj/xcuserdata/
|
||||||
|
*.mobileprovision
|
||||||
|
*.provisionprofile
|
||||||
|
*.p12
|
||||||
|
|||||||
86
CHANGELOG.md
Normal file
86
CHANGELOG.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.3.0 (build 4) - 2026-06-24
|
||||||
|
|
||||||
|
Version 0.3 is focused on making annotation work feel reliable enough for real PDF review: clearer highlights, better comment behavior, safer saving, and release packaging for the Mac App Store.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Added a Settings window for highlight and comment colors, including opacity.
|
||||||
|
- Added drag-and-drop opening when no PDF is open.
|
||||||
|
- Highlighting selected text now creates a highlight immediately instead of opening an empty comment box.
|
||||||
|
- Pressing Return now saves comments and replies; Shift-Return inserts a new line.
|
||||||
|
- Saved text comments now remain visible in macOS Preview and Adobe Acrobat.
|
||||||
|
- Added Mac App Store packaging for bundle ID `net.akkolli.ihatepdfs`.
|
||||||
|
|
||||||
|
### Annotation And Comment Improvements
|
||||||
|
|
||||||
|
- Highlight and comment colors now have stronger default contrast.
|
||||||
|
- Custom highlight and comment colors keep a minimum readable opacity.
|
||||||
|
- New comment popovers focus the text box immediately, so the text cursor appears before typing.
|
||||||
|
- Clicking commented or underlined text reopens the editor more accurately.
|
||||||
|
- Clicking nearby whitespace or the line below a comment no longer opens the popover by mistake.
|
||||||
|
- Empty newly created selected-text comments and free-text notes are discarded when closed, so they do not leave behind blank annotations.
|
||||||
|
- Plain highlights and underlines can remain empty without being deleted.
|
||||||
|
- Comments imported from other PDF readers are shown even when the app-specific comment field is missing.
|
||||||
|
|
||||||
|
### Saving, Sharing, And Document Safety
|
||||||
|
|
||||||
|
- The app now prompts before closing, replacing, or quitting with unsaved annotation changes.
|
||||||
|
- The window uses the native macOS edited-document indicator while annotations or reply drafts are unsaved.
|
||||||
|
- Save is disabled when there is nothing to save.
|
||||||
|
- Save, Save As, and Share warn before omitting an unsent sidebar reply draft.
|
||||||
|
- Share avoids redundant save prompts when the current PDF is already saved.
|
||||||
|
- Save-before-close prompts name the file that would be overwritten.
|
||||||
|
|
||||||
|
### Comments Sidebar
|
||||||
|
|
||||||
|
- The comments sidebar now handles replies, filters, collapsed page groups, and search more consistently.
|
||||||
|
- Matching replies keep their parent thread visible in search results.
|
||||||
|
- Filters that hide every comment now show a clear empty state with a Clear Filters action.
|
||||||
|
- Starting a new reply no longer silently discards a draft for another comment.
|
||||||
|
- Sidebar hover and selection highlights now clear when filters, collapsed groups, or sidebar visibility hide the selected row.
|
||||||
|
- Selecting a sidebar reply scrolls to and highlights the visible parent annotation instead of a hidden reply marker.
|
||||||
|
|
||||||
|
### Search And Navigation
|
||||||
|
|
||||||
|
- Closing PDF search clears match highlights from the document.
|
||||||
|
- Editing the search field clears stale match highlights until the new search is submitted.
|
||||||
|
- Search now reports the current match number while stepping through results.
|
||||||
|
- Page navigation disables unavailable previous/next controls and recovers cleanly from invalid page-number entries.
|
||||||
|
|
||||||
|
### Packaging And Release
|
||||||
|
|
||||||
|
- The app version is now `0.3.0`, build `4`.
|
||||||
|
- Release scripts now build a v0.3 DMG filename by default.
|
||||||
|
- Added a shared release-version script so app bundle versions, DMG names, and App Store package names stay aligned.
|
||||||
|
- Added App Store sandbox entitlements for user-selected PDF read/write access.
|
||||||
|
- Added a signed Mac App Store `.pkg` build path.
|
||||||
|
- Added release QA, App Store packaging, and engineering-size documentation.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Added tests for color preference storage and minimum opacity.
|
||||||
|
- Added tests for tighter text-markup hit testing.
|
||||||
|
- Added tests for PDF drag-and-drop file selection.
|
||||||
|
- Added tests for Return versus Shift-Return commit behavior.
|
||||||
|
- Expanded PDF annotation export tests for Preview-compatible comments, popup cleanup, configured colors, replies, and imported annotations.
|
||||||
|
|
||||||
|
## Version 0.2.0 - 2026-06-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Opening a PDF from Finder Open With now opens in its own window state instead of mirroring into an existing window.
|
||||||
|
- Zoom toolbar and menu commands now apply to the focused PDF window instead of another open window.
|
||||||
|
- Pressing Return in a new comment editor now saves the comment without requiring the mouse.
|
||||||
|
- Page and comments sidebar toolbar icons remain visible in narrow windows.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Sidebar toolbar controls are grouped together in the leading toolbar area for better visibility.
|
||||||
|
|
||||||
|
## Version 0.1.0
|
||||||
|
|
||||||
|
- Initial native macOS SwiftUI/PDFKit release.
|
||||||
|
- Local PDF opening, reading, zoom, fit width, fit page, page navigation, and search.
|
||||||
|
- Highlight, underline, selection-bound comment, free-text annotation, comments sidebar, Save, Save As, and Share workflows.
|
||||||
59
README.md
59
README.md
@@ -1,33 +1,41 @@
|
|||||||
# I Hate PDFs
|
# I Hate PDFs
|
||||||
|
|
||||||
I Hate PDFs is an open-source macOS PDF reader for anyone who hates adobe. I think adobe is .
|
I Hate PDFs is a small native macOS PDF reader for local reading, highlighting, commenting, and review. It uses SwiftUI, AppKit, and PDFKit, keeps documents on your Mac, and avoids accounts, tracking, and cloud upload.
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
This app is entirely vibe coded, but will somehow still be better than adobe acrobate soon.
|
|
||||||
|
|
||||||
Minimum supported macOS version: macOS 13 Ventura.
|
Minimum supported macOS version: macOS 13 Ventura.
|
||||||
|
|
||||||
Supported Mac architectures: Apple Silicon and Intel, subject to the local Swift/Xcode toolchain used to build.
|
Supported Mac architectures: Apple Silicon and Intel.
|
||||||
|
|
||||||
|
## Latest Release
|
||||||
|
|
||||||
|
Current version: `0.3.0` build `4`.
|
||||||
|
|
||||||
|
Download the v0.3 macOS DMG from the GitHub release page:
|
||||||
|
|
||||||
|
<https://github.com/akkolli/ihatepdfs/releases/tag/v0.3>
|
||||||
|
|
||||||
|
Use `IHatePDFs-v0.3-macos.dmg` for direct installation. Open the DMG, then move `I Hate PDFs.app` into `/Applications`.
|
||||||
|
|
||||||
|
The direct-download DMG is separate from the Mac App Store build. The App Store package uses bundle ID `net.akkolli.ihatepdfs` and is built with the sandbox entitlements documented in `docs/APP_STORE.md`.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Open local `.pdf` files from disk.
|
- Open local `.pdf` files from disk.
|
||||||
|
- Drag a PDF onto the empty app window to open it.
|
||||||
- Read with smooth PDFKit scrolling, Retina rendering, zoom, fit-to-width, fit-to-page, and page navigation.
|
- Read with smooth PDFKit scrolling, Retina rendering, zoom, fit-to-width, fit-to-page, and page navigation.
|
||||||
- Search selectable text PDFs from a compact toolbar control.
|
- Search selectable text PDFs from a compact toolbar control.
|
||||||
- Start in a focused single-pane reading layout, with thumbnail and comments sidebars hidden until requested.
|
- Start in a focused single-pane reading layout, with thumbnail and comments sidebars hidden until requested.
|
||||||
- Remember thumbnail and comments sidebar visibility per PDF and coarse window size.
|
- Remember thumbnail and comments sidebar visibility per PDF and coarse window size.
|
||||||
- Toggle a compact page thumbnail/sidebar inspector.
|
- Configure highlight and comment colors, including opacity, from Settings.
|
||||||
- Create selection-bound comments from highlighted PDF text.
|
- Create standalone highlights from selected text.
|
||||||
- Create highlight annotations with anchored optional comments.
|
- Create selected-text comments and underline comments.
|
||||||
- Create underline annotations with optional comments.
|
|
||||||
- Create free-text annotations directly on the page.
|
- Create free-text annotations directly on the page.
|
||||||
- Click annotations in the PDF to reopen and edit the comment in place.
|
- Press Return to save comments and replies, or Shift-Return for a new line.
|
||||||
|
- Click commented or underlined text in the PDF to reopen and edit the comment in place.
|
||||||
- Save annotations directly into the original PDF after an overwrite warning.
|
- Save annotations directly into the original PDF after an overwrite warning.
|
||||||
- Save As a new annotated copy.
|
- Save As a new annotated copy.
|
||||||
- Share the annotated PDF through the native macOS share picker.
|
- Share the annotated PDF through the native macOS share picker.
|
||||||
- Review annotations in a compact list with page number, type, author, date, and first comment line.
|
- Review annotations in a comments sidebar with page grouping, search, filters, replies, edit/delete, and click-to-navigate.
|
||||||
- Use an Acrobat-style comments sidebar with total count, page grouping, collapsible groups, an add-comment affordance, comment search, collapsed type/author/status filters, full text, replies, edit/delete, and click-to-navigate.
|
|
||||||
|
|
||||||
## Build From Source
|
## Build From Source
|
||||||
|
|
||||||
@@ -67,19 +75,34 @@ Create a downloadable `.dmg`:
|
|||||||
scripts/make-dmg.sh
|
scripts/make-dmg.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs.dmg`.
|
The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs-v0.3-macos.dmg` by default.
|
||||||
|
|
||||||
|
Build an App Store upload package after installing the application signing certificate, installer signing certificate, and App Store provisioning profile:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
APP_SIGNING_IDENTITY="3rd Party Mac Developer Application: Your Name (TEAMID)" \
|
||||||
|
INSTALLER_SIGNING_IDENTITY="3rd Party Mac Developer Installer: Your Name (TEAMID)" \
|
||||||
|
PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \
|
||||||
|
scripts/make-app-store-pkg.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The App Store package is written to `dist/IHatePDFs-v0.3-macos-appstore.pkg`. More details are in `docs/APP_STORE.md`.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Download `IHatePDFs.dmg`, open it, and move `I Hate PDFs.app` into `/Applications`. For local development builds that are not notarized, macOS may require opening the app from Finder with Control-click, Open the first time.
|
Download `IHatePDFs-v0.3-macos.dmg` from the latest GitHub release, open it, and move `I Hate PDFs.app` into `/Applications`.
|
||||||
|
|
||||||
|
For local direct-download builds, the app may not be Developer ID notarized. If macOS blocks first launch, open Finder, Control-click `I Hate PDFs.app`, choose Open, then confirm.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The project is a Swift Package with two targets:
|
The project is a Swift Package with two targets:
|
||||||
|
|
||||||
- `IHatePDFsCore`: PDF annotation models and factory helpers.
|
- `IHatePDFsCore`: PDF annotation models, annotation export helpers, hit testing, color preference logic, file selection, and keyboard policies.
|
||||||
- `IHatePDFs`: SwiftUI macOS app, PDFKit bridge, toolbar, menus, sidebars, anchored comment popovers, opening, saving, sharing, and search.
|
- `IHatePDFs`: SwiftUI macOS app, PDFKit bridge, toolbar, menus, sidebars, anchored comment popovers, opening, saving, sharing, and search.
|
||||||
|
|
||||||
|
Engineering rule: keep this a native macOS app with the smallest final bundle that still delivers the required fluidity and functionality. See `docs/ENGINEERING.md` before adding dependencies, bundled assets, PDF engines, runtimes, or broad architectural changes.
|
||||||
|
|
||||||
Useful checks:
|
Useful checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -89,9 +112,9 @@ swift scripts/verify-sample-pdf.swift
|
|||||||
swift scripts/verify-pdf-annotations.swift
|
swift scripts/verify-pdf-annotations.swift
|
||||||
```
|
```
|
||||||
|
|
||||||
The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, and popup annotation dictionaries.
|
The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, contents, and annotation relationship dictionaries.
|
||||||
|
|
||||||
Manual release QA for Preview, Acrobat Reader, and browser PDF viewers is documented in `docs/QA.md`. The macOS design review is documented in `docs/DESIGN_REVIEW.md`.
|
Manual release QA for Preview, Acrobat Reader, and browser PDF viewers is documented in `docs/QA.md`. App Store packaging is documented in `docs/APP_STORE.md`.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
|||||||
30
RELEASE_NOTES.md
Normal file
30
RELEASE_NOTES.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
## I Hate PDFs v0.3.0
|
||||||
|
|
||||||
|
Version 0.3 makes I Hate PDFs much safer and more comfortable for everyday PDF review.
|
||||||
|
|
||||||
|
### What's New
|
||||||
|
|
||||||
|
- Settings for highlight and comment colors, including opacity.
|
||||||
|
- Drag-and-drop opening from the empty app window.
|
||||||
|
- Standalone highlights that do not open a comment editor.
|
||||||
|
- Return saves a comment or reply; Shift-Return inserts a new line.
|
||||||
|
- Better default highlight contrast.
|
||||||
|
- Mac App Store packaging for `net.akkolli.ihatepdfs`.
|
||||||
|
|
||||||
|
### Reliability Fixes
|
||||||
|
|
||||||
|
- Comment text now survives when saved PDFs are opened in macOS Preview and Adobe Acrobat.
|
||||||
|
- The comment editor focuses correctly when a new selected-text comment is created.
|
||||||
|
- Comment popovers now open from the actual annotated text instead of nearby whitespace.
|
||||||
|
- The app warns before unsaved annotations or reply drafts are lost.
|
||||||
|
- Search highlights clear correctly when search is closed or edited.
|
||||||
|
- The comments sidebar keeps threads, filters, replies, and selected highlights in sync.
|
||||||
|
|
||||||
|
### Version
|
||||||
|
|
||||||
|
- App version: `0.3.0`
|
||||||
|
- Build number: `4`
|
||||||
|
- Direct-download DMG name: `IHatePDFs-v0.3-macos.dmg`
|
||||||
|
- Mac App Store package name: `IHatePDFs-v0.3-macos-appstore.pkg`
|
||||||
13
ROADMAP.md
13
ROADMAP.md
@@ -14,7 +14,18 @@
|
|||||||
- `.app` and `.dmg` build scripts.
|
- `.app` and `.dmg` build scripts.
|
||||||
- Visual QA screenshots for empty, reading, popover, comments, and dark-mode states.
|
- Visual QA screenshots for empty, reading, popover, comments, and dark-mode states.
|
||||||
|
|
||||||
## Version 0.2
|
## Shipped In Version 0.3
|
||||||
|
|
||||||
|
- Settings for highlight and comment colors.
|
||||||
|
- Higher-contrast default highlights and comments.
|
||||||
|
- Standalone highlights that do not open a comment editor.
|
||||||
|
- Drag-and-drop PDF opening from the empty app window.
|
||||||
|
- Return-to-save and Shift-Return-for-newline comment behavior.
|
||||||
|
- Preview-compatible exported comments for selected-text markup.
|
||||||
|
- Safer close/open/quit prompts for unsaved annotations and reply drafts.
|
||||||
|
- Mac App Store packaging path for `net.akkolli.ihatepdfs`.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
- More explicit visual selection handles for the active annotation.
|
- More explicit visual selection handles for the active annotation.
|
||||||
- Better undo/redo integration for annotation edits.
|
- Better undo/redo integration for annotation edits.
|
||||||
|
|||||||
10
Signing/IHatePDFs-AppStore.entitlements
Normal file
10
Signing/IHatePDFs-AppStore.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
121
Sources/IHatePDFs/AppSettings.swift
Normal file
121
Sources/IHatePDFs/AppSettings.swift
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import IHatePDFsCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum AppSettings {
|
||||||
|
static let highlightColorStorageKey = "IHatePDFs.highlightColorRGBA.v1"
|
||||||
|
static let commentColorStorageKey = "IHatePDFs.commentColorRGBA.v1"
|
||||||
|
static let defaultHighlightColorStorageValue = storageString(for: AcademicAnnotationPalette.highlight)
|
||||||
|
static let defaultCommentColorStorageValue = storageString(for: AcademicAnnotationPalette.comment)
|
||||||
|
private static let minimumHighlightAlpha: CGFloat = 0.38
|
||||||
|
private static let minimumCommentAlpha: CGFloat = 0.12
|
||||||
|
|
||||||
|
static var highlightColor: NSColor {
|
||||||
|
get {
|
||||||
|
highlightColor(from: UserDefaults.standard.string(forKey: highlightColorStorageKey))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(storageString(forHighlightColor: newValue), forKey: highlightColorStorageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var commentColor: NSColor {
|
||||||
|
get {
|
||||||
|
commentColor(from: UserDefaults.standard.string(forKey: commentColorStorageKey))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(storageString(forCommentColor: newValue), forKey: commentColorStorageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func highlightColor(from storageValue: String?) -> NSColor {
|
||||||
|
AnnotationColorPreference.color(
|
||||||
|
from: storageValue,
|
||||||
|
fallback: AcademicAnnotationPalette.highlight,
|
||||||
|
minimumAlpha: minimumHighlightAlpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func commentColor(from storageValue: String?) -> NSColor {
|
||||||
|
AnnotationColorPreference.color(
|
||||||
|
from: storageValue,
|
||||||
|
fallback: AcademicAnnotationPalette.comment,
|
||||||
|
minimumAlpha: minimumCommentAlpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storageString(for color: NSColor) -> String {
|
||||||
|
AnnotationColorPreference.storageString(for: color)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storageString(for color: Color) -> String {
|
||||||
|
storageString(for: NSColor(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storageString(forHighlightColor color: NSColor) -> String {
|
||||||
|
storageString(for: highlightColor(from: storageString(for: color)))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storageString(forHighlightColor color: Color) -> String {
|
||||||
|
storageString(forHighlightColor: NSColor(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storageString(forCommentColor color: NSColor) -> String {
|
||||||
|
storageString(for: commentColor(from: storageString(for: color)))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storageString(forCommentColor color: Color) -> String {
|
||||||
|
storageString(forCommentColor: NSColor(color))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@AppStorage(AppSettings.highlightColorStorageKey)
|
||||||
|
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
|
||||||
|
@AppStorage(AppSettings.commentColorStorageKey)
|
||||||
|
private var storedCommentColor = AppSettings.defaultCommentColorStorageValue
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Annotations") {
|
||||||
|
ColorPicker(
|
||||||
|
"Highlight color",
|
||||||
|
selection: highlightColor,
|
||||||
|
supportsOpacity: true
|
||||||
|
)
|
||||||
|
|
||||||
|
ColorPicker(
|
||||||
|
"Comment color",
|
||||||
|
selection: commentColor,
|
||||||
|
supportsOpacity: true
|
||||||
|
)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
|
||||||
|
storedCommentColor = AppSettings.defaultCommentColorStorageValue
|
||||||
|
} label: {
|
||||||
|
Label("Reset Annotation Colors", systemImage: "arrow.counterclockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var highlightColor: Binding<Color> {
|
||||||
|
Binding {
|
||||||
|
Color(nsColor: AppSettings.highlightColor(from: storedHighlightColor))
|
||||||
|
} set: { newValue in
|
||||||
|
storedHighlightColor = AppSettings.storageString(forHighlightColor: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var commentColor: Binding<Color> {
|
||||||
|
Binding {
|
||||||
|
Color(nsColor: AppSettings.commentColor(from: storedCommentColor))
|
||||||
|
} set: { newValue in
|
||||||
|
storedCommentColor = AppSettings.storageString(forCommentColor: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,9 @@ struct CommentEditorView: View {
|
|||||||
.onChange(of: model.author) { _ in
|
.onChange(of: model.author) { _ in
|
||||||
model.updateDraft()
|
model.updateDraft()
|
||||||
}
|
}
|
||||||
|
.commitOnPlainReturn {
|
||||||
|
model.commit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
|
|||||||
@@ -3,154 +3,389 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct IHatePDFsApp: App {
|
struct IHatePDFsApp: App {
|
||||||
@StateObject private var appState = AppState()
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
MainView()
|
AppWindowRoot()
|
||||||
.environmentObject(appState)
|
|
||||||
.onOpenURL { url in
|
|
||||||
appState.loadDocument(from: url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.windowStyle(.titleBar)
|
.windowStyle(.titleBar)
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(replacing: .newItem) {
|
AppCommands()
|
||||||
Button("Open...") {
|
}
|
||||||
appState.openDocument()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("o")
|
|
||||||
|
|
||||||
Button("Save") {
|
Settings {
|
||||||
appState.saveDocument()
|
SettingsView()
|
||||||
}
|
}
|
||||||
.keyboardShortcut("s")
|
}
|
||||||
.disabled(appState.document == nil)
|
}
|
||||||
|
|
||||||
Button("Save As...") {
|
@MainActor
|
||||||
appState.saveDocumentAs()
|
private final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
AppStateRegistry.shared.confirmApplicationShouldTerminate()
|
||||||
.disabled(appState.document == nil)
|
? .terminateNow
|
||||||
|
: .terminateCancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button("Share...") {
|
@MainActor
|
||||||
appState.shareDocument()
|
private final class AppStateRegistry {
|
||||||
}
|
static let shared = AppStateRegistry()
|
||||||
.keyboardShortcut("e", modifiers: [.command, .shift])
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Divider()
|
private var appStates: [WeakAppState] = []
|
||||||
|
private(set) var isTerminationApproved = false
|
||||||
|
|
||||||
Button("Close PDF") {
|
func register(_ appState: AppState) {
|
||||||
appState.closeDocument()
|
prune()
|
||||||
}
|
|
||||||
.keyboardShortcut("w")
|
guard !appStates.contains(where: { $0.value === appState }) else {
|
||||||
.disabled(appState.document == nil)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appStates.append(WeakAppState(appState))
|
||||||
|
}
|
||||||
|
|
||||||
|
func unregister(_ appState: AppState) {
|
||||||
|
appStates.removeAll { $0.value == nil || $0.value === appState }
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmApplicationShouldTerminate() -> Bool {
|
||||||
|
prune()
|
||||||
|
|
||||||
|
for appState in appStates.compactMap(\.value) {
|
||||||
|
guard appState.confirmApplicationQuit() else {
|
||||||
|
cancelTerminationApproval()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isTerminationApproved = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelTerminationApproval() {
|
||||||
|
isTerminationApproved = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prune() {
|
||||||
|
appStates.removeAll { $0.value == nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class WeakAppState {
|
||||||
|
weak var value: AppState?
|
||||||
|
|
||||||
|
init(_ value: AppState) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.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
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandGroup(after: .textEditing) {
|
self.window = window
|
||||||
Button("Find in PDF") {
|
previousDelegate = window?.delegate
|
||||||
appState.showSearch()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("f")
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Find Next") {
|
if window?.delegate !== self {
|
||||||
appState.nextSearchResult()
|
window?.delegate = self
|
||||||
}
|
|
||||||
.keyboardShortcut("g")
|
|
||||||
.disabled(appState.searchResults.isEmpty)
|
|
||||||
|
|
||||||
Button("Find Previous") {
|
|
||||||
appState.previousSearchResult()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("g", modifiers: [.command, .shift])
|
|
||||||
.disabled(appState.searchResults.isEmpty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandMenu("View") {
|
updateDocumentState()
|
||||||
Button("Toggle Page Sidebar") {
|
}
|
||||||
appState.showLeftSidebar.toggle()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("0", modifiers: [.command, .option])
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Toggle Comments Sidebar") {
|
func updateDocumentState() {
|
||||||
appState.showCommentsSidebar.toggle()
|
guard let window else { return }
|
||||||
}
|
|
||||||
.keyboardShortcut("1", modifiers: [.command, .option])
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Divider()
|
let representedURL = appState?.documentURL
|
||||||
|
if window.representedURL != representedURL {
|
||||||
Button("Zoom In") {
|
window.representedURL = representedURL
|
||||||
appState.zoomIn()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("+")
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Zoom Out") {
|
|
||||||
appState.zoomOut()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("-")
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Fit to Width") {
|
|
||||||
appState.fitWidth()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("9", modifiers: [.command])
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Fit to Page") {
|
|
||||||
appState.fitPage()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("8", modifiers: [.command])
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Two Pages Continuous") {
|
|
||||||
appState.twoPageContinuous()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("7", modifiers: [.command])
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandMenu("Annotate") {
|
let isDocumentEdited = appState?.hasUnsavedWork == true
|
||||||
Button("Highlight Selection") {
|
if window.isDocumentEdited != isDocumentEdited {
|
||||||
appState.addHighlight()
|
window.isDocumentEdited = isDocumentEdited
|
||||||
}
|
}
|
||||||
.keyboardShortcut("h", modifiers: [.command, .shift])
|
}
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Underline Selection") {
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
appState.addUnderline()
|
if previousDelegate?.windowShouldClose?(sender) == false {
|
||||||
}
|
AppStateRegistry.shared.cancelTerminationApproval()
|
||||||
.keyboardShortcut("u", modifiers: [.command, .shift])
|
return false
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Comment on Selection") {
|
|
||||||
appState.addComment()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
|
|
||||||
Button("Add Free Text") {
|
|
||||||
appState.addFreeText()
|
|
||||||
}
|
|
||||||
.keyboardShortcut("t", modifiers: [.command, .shift])
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandGroup(after: .windowArrangement) {
|
if AppStateRegistry.shared.isTerminationApproved {
|
||||||
Button("Minimize") {
|
return true
|
||||||
appState.minimizeWindow()
|
}
|
||||||
}
|
|
||||||
.keyboardShortcut("m", modifiers: [.command])
|
|
||||||
|
|
||||||
Button("Toggle Full Screen") {
|
return appState?.confirmDocumentWindowClose() ?? true
|
||||||
appState.toggleFullScreen()
|
}
|
||||||
|
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
previousDelegate?.windowWillClose?(notification)
|
||||||
|
|
||||||
|
if window?.delegate === self {
|
||||||
|
window?.delegate = previousDelegate
|
||||||
|
}
|
||||||
|
window = nil
|
||||||
|
previousDelegate = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
if window?.delegate === self {
|
||||||
|
window?.delegate = previousDelegate
|
||||||
}
|
}
|
||||||
.keyboardShortcut("f", modifiers: [.command, .control])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 canSaveDocument: Bool {
|
||||||
|
appState?.canSaveDocument == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var saveHelpText: String {
|
||||||
|
appState?.saveHelpText ?? "Open a PDF before saving."
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Commands {
|
||||||
|
CommandGroup(replacing: .newItem) {
|
||||||
|
Button("Open...") {
|
||||||
|
appState?.openDocument()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("o")
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Close PDF") {
|
||||||
|
appState?.closeDocument()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("w")
|
||||||
|
.disabled(!hasDocument)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandMenu("View") {
|
||||||
|
Button("Toggle Page Sidebar") {
|
||||||
|
appState?.showLeftSidebar.toggle()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("0", modifiers: [.command, .option])
|
||||||
|
.disabled(!hasDocument)
|
||||||
|
|
||||||
|
Button("Toggle Comments Sidebar") {
|
||||||
|
appState?.showCommentsSidebar.toggle()
|
||||||
|
}
|
||||||
|
.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("Highlight Selection") {
|
||||||
|
appState?.addHighlight()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||||
|
.disabled(!hasDocument || !hasTextSelection)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import IHatePDFsCore
|
import IHatePDFsCore
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
@EnvironmentObject private var appState: AppState
|
@EnvironmentObject private var appState: AppState
|
||||||
@@ -56,12 +57,13 @@ private struct PDFReaderView: View {
|
|||||||
|
|
||||||
private struct EmptyDocumentView: View {
|
private struct EmptyDocumentView: View {
|
||||||
@EnvironmentObject private var appState: AppState
|
@EnvironmentObject private var appState: AppState
|
||||||
|
@State private var isDropTargeted = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "doc.richtext")
|
Image(systemName: isDropTargeted ? "tray.and.arrow.down" : "doc.richtext")
|
||||||
.font(.system(size: 48, weight: .regular))
|
.font(.system(size: 48, weight: .regular))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(isDropTargeted ? .accentColor : .secondary)
|
||||||
|
|
||||||
Text("Open a PDF")
|
Text("Open a PDF")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@@ -82,6 +84,20 @@ private struct EmptyDocumentView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(nsColor: .windowBackgroundColor))
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(
|
||||||
|
isDropTargeted ? Color.accentColor : Color.clear,
|
||||||
|
style: StrokeStyle(lineWidth: 2, dash: [8, 6])
|
||||||
|
)
|
||||||
|
.padding(18)
|
||||||
|
}
|
||||||
|
.onDrop(
|
||||||
|
of: [UTType.fileURL.identifier],
|
||||||
|
isTargeted: $isDropTargeted
|
||||||
|
) { providers in
|
||||||
|
appState.openDroppedDocument(from: providers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +113,9 @@ private struct StatusBarView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if appState.document != nil {
|
if appState.document != nil {
|
||||||
|
if appState.hasUnsentSidebarReplyDraft {
|
||||||
|
Text("Reply draft")
|
||||||
|
}
|
||||||
Text("\(appState.annotations.count) annotations")
|
Text("\(appState.annotations.count) annotations")
|
||||||
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
|
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
|
||||||
}
|
}
|
||||||
@@ -129,6 +148,15 @@ private struct ReaderToolbar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
.disabled(appState.document == nil)
|
.disabled(appState.document == nil)
|
||||||
.help("Toggle Page Sidebar")
|
.help("Toggle Page Sidebar")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
appState.showCommentsSidebar.toggle()
|
||||||
|
} label: {
|
||||||
|
Label("Comments Sidebar", systemImage: "sidebar.right")
|
||||||
|
}
|
||||||
|
.disabled(appState.document == nil)
|
||||||
|
.help(appState.showCommentsSidebar ? "Hide Comments Sidebar" : "Show Comments Sidebar")
|
||||||
|
.accessibilityLabel("Toggle Comments Sidebar")
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .principal) {
|
ToolbarItemGroup(placement: .principal) {
|
||||||
@@ -137,7 +165,7 @@ private struct ReaderToolbar: ToolbarContent {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Previous Page", systemImage: "chevron.up")
|
Label("Previous Page", systemImage: "chevron.up")
|
||||||
}
|
}
|
||||||
.disabled(appState.document == nil)
|
.disabled(!appState.canGoToPreviousPage)
|
||||||
.help("Previous Page")
|
.help("Previous Page")
|
||||||
|
|
||||||
TextField("Page", text: $appState.pageText)
|
TextField("Page", text: $appState.pageText)
|
||||||
@@ -156,7 +184,7 @@ private struct ReaderToolbar: ToolbarContent {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Next Page", systemImage: "chevron.down")
|
Label("Next Page", systemImage: "chevron.down")
|
||||||
}
|
}
|
||||||
.disabled(appState.document == nil)
|
.disabled(!appState.canGoToNextPage)
|
||||||
.help("Next Page")
|
.help("Next Page")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +244,7 @@ private struct ReaderToolbar: ToolbarContent {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Highlight", systemImage: "highlighter")
|
Label("Highlight", systemImage: "highlighter")
|
||||||
}
|
}
|
||||||
.disabled(appState.document == nil)
|
.disabled(appState.document == nil || !appState.hasTextSelection)
|
||||||
.help("Highlight Selection")
|
.help("Highlight Selection")
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -224,7 +252,7 @@ private struct ReaderToolbar: ToolbarContent {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Underline", systemImage: "underline")
|
Label("Underline", systemImage: "underline")
|
||||||
}
|
}
|
||||||
.disabled(appState.document == nil)
|
.disabled(appState.document == nil || !appState.hasTextSelection)
|
||||||
.help("Underline Selection")
|
.help("Underline Selection")
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -234,7 +262,7 @@ private struct ReaderToolbar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel("Comment on Selection")
|
.accessibilityLabel("Comment on Selection")
|
||||||
.help("Comment on Selection")
|
.help("Comment on Selection")
|
||||||
.disabled(appState.document == nil)
|
.disabled(appState.document == nil || !appState.hasTextSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItemGroup {
|
ToolbarItemGroup {
|
||||||
@@ -277,8 +305,8 @@ private struct ReaderToolbar: ToolbarContent {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Save", systemImage: "square.and.arrow.down")
|
Label("Save", systemImage: "square.and.arrow.down")
|
||||||
}
|
}
|
||||||
.disabled(appState.document == nil)
|
.disabled(!appState.canSaveDocument)
|
||||||
.help("Save PDF")
|
.help(appState.saveHelpText)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
appState.shareDocument()
|
appState.shareDocument()
|
||||||
@@ -288,16 +316,5 @@ private struct ReaderToolbar: ToolbarContent {
|
|||||||
.disabled(appState.document == nil)
|
.disabled(appState.document == nil)
|
||||||
.help("Share PDF")
|
.help("Share PDF")
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
appState.showCommentsSidebar.toggle()
|
|
||||||
} label: {
|
|
||||||
Label("Comments Sidebar", systemImage: "sidebar.right")
|
|
||||||
}
|
|
||||||
.disabled(appState.document == nil)
|
|
||||||
.help(appState.showCommentsSidebar ? "Hide Comments Sidebar" : "Show Comments Sidebar")
|
|
||||||
.accessibilityLabel("Toggle Comments Sidebar")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SwiftUI
|
|||||||
final class AcademicPDFView: PDFView {
|
final class AcademicPDFView: PDFView {
|
||||||
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
|
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
|
||||||
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
|
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
|
||||||
|
var onCancelPlacement: (() -> Void)?
|
||||||
var onSelectionComment: (() -> Void)?
|
var onSelectionComment: (() -> Void)?
|
||||||
var onPreviousPageKey: (() -> Void)?
|
var onPreviousPageKey: (() -> Void)?
|
||||||
var onNextPageKey: (() -> Void)?
|
var onNextPageKey: (() -> Void)?
|
||||||
@@ -91,6 +92,11 @@ final class AcademicPDFView: PDFView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func keyDown(with event: NSEvent) {
|
override func keyDown(with event: NSEvent) {
|
||||||
|
if event.keyCode == 53, placementTool != nil {
|
||||||
|
onCancelPlacement?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
|
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
|
||||||
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
|
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
|
||||||
super.keyDown(with: event)
|
super.keyDown(with: event)
|
||||||
@@ -156,24 +162,15 @@ final class AcademicPDFView: PDFView {
|
|||||||
|
|
||||||
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
|
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
|
||||||
if let direct = page.annotation(at: point),
|
if let direct = page.annotation(at: point),
|
||||||
let editable = editableParent(for: direct, on: page) {
|
let editable = editableParent(for: direct, on: page),
|
||||||
|
isInteractionPoint(point, on: direct, editable: editable) {
|
||||||
return editable
|
return editable
|
||||||
}
|
}
|
||||||
|
|
||||||
for annotation in page.annotations.reversed() {
|
for annotation in page.annotations.reversed() {
|
||||||
guard let editable = editableParent(for: annotation, on: page) else { continue }
|
guard let editable = editableParent(for: annotation, on: page) else { continue }
|
||||||
|
|
||||||
if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) {
|
if isInteractionPoint(point, on: annotation, editable: editable) {
|
||||||
return editable
|
|
||||||
}
|
|
||||||
|
|
||||||
if let popup = editable.popup,
|
|
||||||
popup.bounds.insetBy(dx: -10, dy: -10).contains(point) {
|
|
||||||
return editable
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTextMarkup(editable),
|
|
||||||
textMarkupInteractionBounds(for: editable, on: page).contains(point) {
|
|
||||||
return editable
|
return editable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +178,31 @@ final class AcademicPDFView: PDFView {
|
|||||||
return nil
|
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? {
|
private func editableParent(for annotation: PDFAnnotation, on page: PDFPage) -> PDFAnnotation? {
|
||||||
if let owner = popupOwner(for: annotation, on: page) {
|
if let owner = popupOwner(for: annotation, on: page) {
|
||||||
return isEditableAcademicAnnotation(owner) ? owner : nil
|
return isEditableAcademicAnnotation(owner) ? owner : nil
|
||||||
@@ -228,20 +250,6 @@ final class AcademicPDFView: PDFView {
|
|||||||
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func textMarkupInteractionBounds(
|
|
||||||
for annotation: PDFAnnotation,
|
|
||||||
on page: PDFPage
|
|
||||||
) -> CGRect {
|
|
||||||
var bounds = annotation.bounds.insetBy(dx: -48, dy: -48)
|
|
||||||
|
|
||||||
if let popup = annotation.popup {
|
|
||||||
bounds = bounds.union(popup.bounds.insetBy(dx: -16, dy: -16))
|
|
||||||
}
|
|
||||||
|
|
||||||
let pageBounds = page.bounds(for: displayBox).insetBy(dx: -64, dy: -64)
|
|
||||||
return bounds.intersection(pageBounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func closeNativePopups(on page: PDFPage) {
|
private func closeNativePopups(on page: PDFPage) {
|
||||||
for annotation in page.annotations {
|
for annotation in page.annotations {
|
||||||
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
|
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
|
||||||
@@ -253,8 +261,16 @@ final class AcademicPDFView: PDFView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
|
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
|
||||||
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|
if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) {
|
||||||
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|
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: .text)
|
||||||
|| AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
|
|| AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
|
||||||
}
|
}
|
||||||
@@ -279,6 +295,11 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
|||||||
appState.placePendingAnnotation(on: page, near: point)
|
appState.placePendingAnnotation(on: page, near: point)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
view.onCancelPlacement = {
|
||||||
|
Task { @MainActor in
|
||||||
|
appState.cancelPlacementTool()
|
||||||
|
}
|
||||||
|
}
|
||||||
view.onSelectionComment = {
|
view.onSelectionComment = {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
appState.addComment()
|
appState.addComment()
|
||||||
@@ -364,6 +385,45 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
|||||||
of: view,
|
of: view,
|
||||||
preferredEdge: preferredEdge(for: anchor, in: 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) {
|
private func dismissCurrent(commit: Bool) {
|
||||||
|
|||||||
106
Sources/IHatePDFs/ReturnKeyCommitMonitor.swift
Normal file
106
Sources/IHatePDFs/ReturnKeyCommitMonitor.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ struct CommentsReviewSidebar: View {
|
|||||||
@State private var showsSearch = false
|
@State private var showsSearch = false
|
||||||
@State private var showsFilters = false
|
@State private var showsFilters = false
|
||||||
@State private var showsAdvancedFilters = false
|
@State private var showsAdvancedFilters = false
|
||||||
|
@FocusState private var isCommentSearchFocused: Bool
|
||||||
|
|
||||||
private var groupedComments: [(pageIndex: Int, items: [AnnotationSnapshot])] {
|
private var groupedComments: [(pageIndex: Int, items: [AnnotationSnapshot])] {
|
||||||
let grouped = Dictionary(grouping: appState.topLevelComments, by: \.pageIndex)
|
let grouped = Dictionary(grouping: appState.topLevelComments, by: \.pageIndex)
|
||||||
@@ -106,6 +107,27 @@ struct CommentsReviewSidebar: View {
|
|||||||
.sorted { $0.pageIndex < $1.pageIndex }
|
.sorted { $0.pageIndex < $1.pageIndex }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var visibleCommentCount: Int {
|
||||||
|
appState.topLevelComments.reduce(0) { partial, item in
|
||||||
|
partial + 1 + (appState.repliesByParent[item.id]?.count ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isFilteringComments: Bool {
|
||||||
|
hasActiveCommentSearch || hasActiveCommentFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasActiveCommentSearch: Bool {
|
||||||
|
!appState.commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasActiveCommentFilters: Bool {
|
||||||
|
appState.commentFilter != .all
|
||||||
|
|| appState.selectedKindFilter != nil
|
||||||
|
|| appState.selectedAuthorFilter != "All Authors"
|
||||||
|
|| appState.selectedStatusFilter != ReviewState.allStatuses
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
@@ -132,7 +154,7 @@ struct CommentsReviewSidebar: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Text("\(appState.annotations.count)")
|
Text("\(visibleCommentCount)")
|
||||||
.font(.headline.monospacedDigit())
|
.font(.headline.monospacedDigit())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -141,18 +163,31 @@ struct CommentsReviewSidebar: View {
|
|||||||
|
|
||||||
Button {
|
Button {
|
||||||
showsSearch.toggle()
|
showsSearch.toggle()
|
||||||
|
if showsSearch {
|
||||||
|
focusCommentSearch()
|
||||||
|
} else {
|
||||||
|
isCommentSearchFocused = false
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Search Comments", systemImage: showsSearch ? "magnifyingglass.circle.fill" : "magnifyingglass")
|
Label(
|
||||||
|
"Search Comments",
|
||||||
|
systemImage: (showsSearch || hasActiveCommentSearch) ? "magnifyingglass.circle.fill" : "magnifyingglass"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
|
.foregroundStyle(hasActiveCommentSearch ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
|
||||||
.help("Search Comments")
|
.help("Search Comments")
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showsFilters.toggle()
|
showsFilters.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Filter Comments", systemImage: showsFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
Label(
|
||||||
|
"Filter Comments",
|
||||||
|
systemImage: (showsFilters || hasActiveCommentFilters) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
|
.foregroundStyle(hasActiveCommentFilters ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
|
||||||
.help("Filter Comments")
|
.help("Filter Comments")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -184,6 +219,7 @@ struct CommentsReviewSidebar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!appState.hasTextSelection)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.help("Select text, then add a comment")
|
.help("Select text, then add a comment")
|
||||||
@@ -194,6 +230,10 @@ struct CommentsReviewSidebar: View {
|
|||||||
if showsSearch {
|
if showsSearch {
|
||||||
TextField("Search comments", text: $appState.commentSearchText)
|
TextField("Search comments", text: $appState.commentSearchText)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.focused($isCommentSearchFocused)
|
||||||
|
.onAppear {
|
||||||
|
focusCommentSearch()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if showsFilters {
|
if showsFilters {
|
||||||
@@ -238,23 +278,75 @@ struct CommentsReviewSidebar: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func focusCommentSearch() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
isCommentSearchFocused = true
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
isCommentSearchFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var commentList: some View {
|
private var commentList: some View {
|
||||||
ScrollView {
|
Group {
|
||||||
LazyVStack(alignment: .leading, spacing: 0) {
|
if groupedComments.isEmpty {
|
||||||
ForEach(groupedComments, id: \.pageIndex) { group in
|
CommentsEmptyState(isFiltering: isFilteringComments)
|
||||||
PageCommentGroup(
|
} else {
|
||||||
pageIndex: group.pageIndex,
|
ScrollView {
|
||||||
items: group.items,
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
repliesByParent: appState.repliesByParent,
|
ForEach(groupedComments, id: \.pageIndex) { group in
|
||||||
showsPageHeader: appState.pageCount > 1
|
PageCommentGroup(
|
||||||
)
|
pageIndex: group.pageIndex,
|
||||||
|
items: group.items,
|
||||||
|
repliesByParent: appState.repliesByParent,
|
||||||
|
showsPageHeader: appState.pageCount > 1,
|
||||||
|
isFiltering: isFilteringComments
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct CommentsEmptyState: View {
|
||||||
|
@EnvironmentObject private var appState: AppState
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
let isFiltering: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 9) {
|
||||||
|
Image(systemName: isFiltering ? "line.3.horizontal.decrease.circle" : "text.bubble")
|
||||||
|
.font(.system(size: 28, weight: .regular))
|
||||||
|
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
|
||||||
|
|
||||||
|
Text(isFiltering ? "No matching comments" : "No comments yet")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||||
|
|
||||||
|
Text(isFiltering ? "Adjust the search or filters to show more comments." : "Select text in the PDF, then add a comment.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
if isFiltering {
|
||||||
|
Button {
|
||||||
|
appState.clearCommentFilters()
|
||||||
|
} label: {
|
||||||
|
Label("Clear Filters", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct PageCommentGroup: View {
|
private struct PageCommentGroup: View {
|
||||||
@EnvironmentObject private var appState: AppState
|
@EnvironmentObject private var appState: AppState
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@@ -262,40 +354,37 @@ private struct PageCommentGroup: View {
|
|||||||
let items: [AnnotationSnapshot]
|
let items: [AnnotationSnapshot]
|
||||||
let repliesByParent: [String: [AnnotationSnapshot]]
|
let repliesByParent: [String: [AnnotationSnapshot]]
|
||||||
let showsPageHeader: Bool
|
let showsPageHeader: Bool
|
||||||
|
let isFiltering: Bool
|
||||||
|
|
||||||
private var isCollapsed: Bool {
|
private var isCollapsed: Bool {
|
||||||
showsPageHeader && appState.collapsedPageIndexes.contains(pageIndex)
|
showsPageHeader && !isFiltering && appState.collapsedPageIndexes.contains(pageIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibleItemCount: Int {
|
||||||
|
items.reduce(0) { partial, item in
|
||||||
|
partial + 1 + (repliesByParent[item.id]?.count ?? 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if showsPageHeader {
|
if showsPageHeader {
|
||||||
Button {
|
if isFiltering {
|
||||||
if isCollapsed {
|
pageHeader
|
||||||
appState.collapsedPageIndexes.remove(pageIndex)
|
.help("Filtered results are expanded")
|
||||||
} else {
|
} else {
|
||||||
appState.collapsedPageIndexes.insert(pageIndex)
|
Button {
|
||||||
|
if isCollapsed {
|
||||||
|
appState.collapsedPageIndexes.remove(pageIndex)
|
||||||
|
} else {
|
||||||
|
appState.collapsedPageIndexes.insert(pageIndex)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
pageHeader
|
||||||
}
|
}
|
||||||
} label: {
|
.buttonStyle(.plain)
|
||||||
HStack {
|
.help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments")
|
||||||
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
|
|
||||||
.font(.caption2.weight(.semibold))
|
|
||||||
.frame(width: 12)
|
|
||||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
|
||||||
Text("Page \(pageIndex + 1)")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
|
||||||
Spacer()
|
|
||||||
Text("\(items.count)")
|
|
||||||
.font(.caption.monospacedDigit())
|
|
||||||
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.top, 7)
|
|
||||||
.padding(.bottom, 5)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isCollapsed {
|
if !isCollapsed {
|
||||||
@@ -307,6 +396,25 @@ private struct PageCommentGroup: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var pageHeader: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.frame(width: 12)
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
Text("Page \(pageIndex + 1)")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
Spacer()
|
||||||
|
Text("\(visibleItemCount)")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.top, 7)
|
||||||
|
.padding(.bottom, 5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CommentRow: View {
|
private struct CommentRow: View {
|
||||||
@@ -574,6 +682,11 @@ private struct SidebarReplyComposer: View {
|
|||||||
isFocused = true
|
isFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.commitOnPlainReturn {
|
||||||
|
if !appState.sidebarReplyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
appState.commitSidebarReply()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
84
Sources/IHatePDFsCore/AnnotationColorPreference.swift
Normal file
84
Sources/IHatePDFsCore/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())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,16 @@ import PDFKit
|
|||||||
|
|
||||||
public enum AcademicAnnotationPalette {
|
public enum AcademicAnnotationPalette {
|
||||||
public static let comment = NSColor(
|
public static let comment = NSColor(
|
||||||
calibratedRed: 0.88,
|
calibratedRed: 0.98,
|
||||||
green: 0.72,
|
green: 0.64,
|
||||||
blue: 0.46,
|
blue: 0.16,
|
||||||
alpha: 0.10
|
alpha: 0.30
|
||||||
)
|
)
|
||||||
public static let highlight = NSColor(
|
public static let highlight = NSColor(
|
||||||
calibratedRed: 0.88,
|
calibratedRed: 1.0,
|
||||||
green: 0.72,
|
green: 0.78,
|
||||||
blue: 0.46,
|
blue: 0.0,
|
||||||
alpha: 0.24
|
alpha: 0.52
|
||||||
)
|
)
|
||||||
public static let underline = NSColor(
|
public static let underline = NSColor(
|
||||||
calibratedRed: 0.48,
|
calibratedRed: 0.48,
|
||||||
@@ -58,10 +58,13 @@ public enum MarkupAnnotationStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var color: NSColor {
|
func color(
|
||||||
|
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
|
||||||
|
commentColor: NSColor = AcademicAnnotationPalette.comment
|
||||||
|
) -> NSColor {
|
||||||
switch self {
|
switch self {
|
||||||
case .comment: return AcademicAnnotationPalette.comment
|
case .comment: return commentColor
|
||||||
case .highlight: return AcademicAnnotationPalette.highlight
|
case .highlight: return highlightColor
|
||||||
case .underline: return AcademicAnnotationPalette.underline
|
case .underline: return AcademicAnnotationPalette.underline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,8 @@ public enum AnnotationFactory {
|
|||||||
style: MarkupAnnotationStyle,
|
style: MarkupAnnotationStyle,
|
||||||
comment: String,
|
comment: String,
|
||||||
author: String,
|
author: String,
|
||||||
|
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
|
||||||
|
commentColor: NSColor = AcademicAnnotationPalette.comment,
|
||||||
date: Date = Date()
|
date: Date = Date()
|
||||||
) -> [AnnotationInsertion] {
|
) -> [AnnotationInsertion] {
|
||||||
let lineSelections = selection.selectionsByLine()
|
let lineSelections = selection.selectionsByLine()
|
||||||
@@ -120,7 +125,7 @@ public enum AnnotationFactory {
|
|||||||
}
|
}
|
||||||
let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil)
|
let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil)
|
||||||
annotation.markupType = style.markupType
|
annotation.markupType = style.markupType
|
||||||
annotation.color = style.color
|
annotation.color = style.color(highlightColor: highlightColor, commentColor: commentColor)
|
||||||
annotation.quadrilateralPoints = group.rects.flatMap { rect in
|
annotation.quadrilateralPoints = group.rects.flatMap { rect in
|
||||||
quadPoints(for: rect, relativeTo: unionRect)
|
quadPoints(for: rect, relativeTo: unionRect)
|
||||||
}
|
}
|
||||||
@@ -266,7 +271,9 @@ public enum AnnotationFactory {
|
|||||||
date: Date
|
date: Date
|
||||||
) {
|
) {
|
||||||
AnnotationKeys.setCommentText(comment, for: annotation)
|
AnnotationKeys.setCommentText(comment, for: annotation)
|
||||||
annotation.contents = comment
|
annotation.contents = comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? nil
|
||||||
|
: comment
|
||||||
annotation.userName = author
|
annotation.userName = author
|
||||||
annotation.modificationDate = date
|
annotation.modificationDate = date
|
||||||
annotation.shouldDisplay = true
|
annotation.shouldDisplay = true
|
||||||
@@ -357,6 +364,51 @@ public enum AnnotationFactory {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
public static func restoreCommentTextForExport(_ annotation: PDFAnnotation) -> Bool {
|
public static func restoreCommentTextForExport(_ annotation: PDFAnnotation) -> Bool {
|
||||||
let contents = AnnotationKeys.commentText(for: annotation)
|
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
|
let exportedContents = contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
? nil
|
? nil
|
||||||
: contents
|
: contents
|
||||||
|
|||||||
46
Sources/IHatePDFsCore/AnnotationHitTesting.swift
Normal file
46
Sources/IHatePDFsCore/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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,11 +157,16 @@ public enum AnnotationKeys {
|
|||||||
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
|
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
|
||||||
|
|
||||||
public static func commentText(for annotation: PDFAnnotation) -> String {
|
public static func commentText(for annotation: PDFAnnotation) -> String {
|
||||||
if let value = annotation.value(forAnnotationKey: appCommentText) as? String {
|
if let value = annotation.value(forAnnotationKey: appCommentText) as? String,
|
||||||
|
!value.isEmpty {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
return annotation.contents ?? ""
|
if let contents = annotation.contents, !contents.isEmpty {
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
|
||||||
|
return annotation.popup?.contents ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func setCommentText(_ text: String, for annotation: PDFAnnotation) {
|
public static func setCommentText(_ text: String, for annotation: PDFAnnotation) {
|
||||||
@@ -287,54 +292,42 @@ public enum AnnotationKeys {
|
|||||||
public enum AnnotationReader {
|
public enum AnnotationReader {
|
||||||
public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] {
|
public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] {
|
||||||
var result: [AnnotationSnapshot] = []
|
var result: [AnnotationSnapshot] = []
|
||||||
|
var namedAnnotationIDs: [String: String]?
|
||||||
|
|
||||||
for pageIndex in 0..<document.pageCount {
|
for pageIndex in 0..<document.pageCount {
|
||||||
guard let page = document.page(at: pageIndex) else { continue }
|
guard let page = document.page(at: pageIndex) else { continue }
|
||||||
|
result.append(contentsOf: snapshots(
|
||||||
for (annotationIndex, annotation) in page.annotations.enumerated() {
|
in: document,
|
||||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { continue }
|
page: page,
|
||||||
|
pageIndex: pageIndex,
|
||||||
let kind = AcademicAnnotationKind(annotation: annotation)
|
namedAnnotationIDs: &namedAnnotationIDs
|
||||||
let contents = AnnotationKeys.commentText(for: annotation)
|
))
|
||||||
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 = AnnotationKeys.parentID(for: annotation, document: document)
|
|
||||||
|
|
||||||
result.append(
|
|
||||||
AnnotationSnapshot(
|
|
||||||
id: id,
|
|
||||||
pageIndex: pageIndex,
|
|
||||||
pageLabel: pageLabel,
|
|
||||||
annotationIndex: annotationIndex,
|
|
||||||
kind: kind,
|
|
||||||
author: author,
|
|
||||||
createdAt: createdAt,
|
|
||||||
modifiedAt: annotation.modificationDate,
|
|
||||||
status: status,
|
|
||||||
contents: contents,
|
|
||||||
bounds: annotation.bounds,
|
|
||||||
annotation: annotation,
|
|
||||||
page: page,
|
|
||||||
parentID: parentID
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.sorted { left, right in
|
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 {
|
if left.pageIndex != right.pageIndex {
|
||||||
return left.pageIndex < right.pageIndex
|
return left.pageIndex < right.pageIndex
|
||||||
}
|
}
|
||||||
@@ -344,4 +337,112 @@ public enum AnnotationReader {
|
|||||||
return left.bounds.minX < right.bounds.minX
|
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)
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
Sources/IHatePDFsCore/PDFFileSelection.swift
Normal file
19
Sources/IHatePDFsCore/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
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Sources/IHatePDFsCore/ReturnKeyCommitPolicy.swift
Normal file
16
Sources/IHatePDFsCore/ReturnKeyCommitPolicy.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum ReturnKeyCommitPolicy {
|
||||||
|
public static func shouldCommit(
|
||||||
|
keyCode: UInt16,
|
||||||
|
shift: Bool,
|
||||||
|
option: Bool,
|
||||||
|
command: Bool,
|
||||||
|
control: Bool,
|
||||||
|
isEditableMultilineText: Bool
|
||||||
|
) -> Bool {
|
||||||
|
guard isEditableMultilineText else { return false }
|
||||||
|
guard keyCode == 36 || keyCode == 76 else { return false }
|
||||||
|
return !shift && !option && !command && !control
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import XCTest
|
||||||
|
import AppKit
|
||||||
|
@testable import IHatePDFsCore
|
||||||
|
|
||||||
|
final class AnnotationColorPreferenceTests: XCTestCase {
|
||||||
|
func testColorPreferenceRoundTripsRGBAStorage() throws {
|
||||||
|
let color = NSColor(deviceRed: 0.25, green: 0.5, blue: 0.75, alpha: 0.4)
|
||||||
|
let storage = AnnotationColorPreference.storageString(for: color)
|
||||||
|
XCTAssertEqual(storage, "#4080BF66")
|
||||||
|
|
||||||
|
let decoded = AnnotationColorPreference.color(
|
||||||
|
from: storage,
|
||||||
|
fallback: AcademicAnnotationPalette.highlight
|
||||||
|
)
|
||||||
|
let components = try rgbaComponents(decoded)
|
||||||
|
|
||||||
|
XCTAssertEqual(components.red, 0x40 / 255, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(components.green, 0x80 / 255, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(components.blue, 0xBF / 255, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(components.alpha, 0x66 / 255, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testColorPreferenceUsesFallbackForInvalidStorage() throws {
|
||||||
|
let decoded = AnnotationColorPreference.color(
|
||||||
|
from: "not-a-color",
|
||||||
|
fallback: AcademicAnnotationPalette.comment
|
||||||
|
)
|
||||||
|
|
||||||
|
try XCTAssertColor(decoded, equals: AcademicAnnotationPalette.comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testColorPreferenceAppliesMinimumAlphaWithoutChangingRGB() throws {
|
||||||
|
let decoded = AnnotationColorPreference.color(
|
||||||
|
from: "#33669905",
|
||||||
|
fallback: AcademicAnnotationPalette.highlight,
|
||||||
|
minimumAlpha: 0.3
|
||||||
|
)
|
||||||
|
let components = try rgbaComponents(decoded)
|
||||||
|
|
||||||
|
XCTAssertEqual(components.red, 0x33 / 255, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(components.green, 0x66 / 255, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(components.blue, 0x99 / 255, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(components.alpha, 0.3, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func XCTAssertColor(
|
||||||
|
_ actual: NSColor,
|
||||||
|
equals expected: NSColor,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) throws {
|
||||||
|
let actualComponents = try rgbaComponents(actual, file: file, line: line)
|
||||||
|
let expectedComponents = try rgbaComponents(expected, file: file, line: line)
|
||||||
|
|
||||||
|
XCTAssertEqual(actualComponents.red, expectedComponents.red, accuracy: 0.001, file: file, line: line)
|
||||||
|
XCTAssertEqual(actualComponents.green, expectedComponents.green, accuracy: 0.001, file: file, line: line)
|
||||||
|
XCTAssertEqual(actualComponents.blue, expectedComponents.blue, accuracy: 0.001, file: file, line: line)
|
||||||
|
XCTAssertEqual(actualComponents.alpha, expectedComponents.alpha, accuracy: 0.001, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rgbaComponents(
|
||||||
|
_ color: NSColor,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) throws -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
|
||||||
|
let rgb = try XCTUnwrap(color.usingColorSpace(.deviceRGB), file: file, line: line)
|
||||||
|
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 (red, green, blue, alpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,97 @@ final class AnnotationFactoryTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testHighlightUsesHigherContrastDefaultColor() throws {
|
||||||
|
let document = try makeSelectableTextDocument()
|
||||||
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
||||||
|
let insertion = try XCTUnwrap(
|
||||||
|
AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .highlight,
|
||||||
|
comment: "",
|
||||||
|
author: "Professor"
|
||||||
|
).first
|
||||||
|
)
|
||||||
|
|
||||||
|
let annotationColor = try rgbaComponents(insertion.annotation.color)
|
||||||
|
let defaultColor = try rgbaComponents(AcademicAnnotationPalette.highlight)
|
||||||
|
XCTAssertEqual(annotationColor.red, defaultColor.red, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(annotationColor.green, defaultColor.green, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(annotationColor.blue, defaultColor.blue, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(annotationColor.alpha, defaultColor.alpha, accuracy: 0.001)
|
||||||
|
XCTAssertGreaterThanOrEqual(annotationColor.alpha, 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHighlightCreatedWithoutCommentHasNoPopupOrCommentText() throws {
|
||||||
|
let document = try makeSelectableTextDocument()
|
||||||
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
||||||
|
let insertion = try XCTUnwrap(
|
||||||
|
AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .highlight,
|
||||||
|
comment: "",
|
||||||
|
author: "Professor"
|
||||||
|
).first
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertNil(insertion.popup)
|
||||||
|
XCTAssertEqual(AnnotationKeys.commentText(for: insertion.annotation), "")
|
||||||
|
XCTAssertNil(insertion.annotation.contents)
|
||||||
|
XCTAssertEqual(AcademicAnnotationKind(annotation: insertion.annotation), .highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHighlightUsesConfiguredColor() throws {
|
||||||
|
let document = try makeSelectableTextDocument()
|
||||||
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
||||||
|
let configuredColor = NSColor(
|
||||||
|
calibratedRed: 0.18,
|
||||||
|
green: 0.58,
|
||||||
|
blue: 0.95,
|
||||||
|
alpha: 0.52
|
||||||
|
)
|
||||||
|
let insertion = try XCTUnwrap(
|
||||||
|
AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .highlight,
|
||||||
|
comment: "",
|
||||||
|
author: "Professor",
|
||||||
|
highlightColor: configuredColor
|
||||||
|
).first
|
||||||
|
)
|
||||||
|
|
||||||
|
try XCTAssertColor(insertion.annotation.color, equals: configuredColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectionBoundCommentUsesConfiguredColor() throws {
|
||||||
|
let document = try makeSelectableTextDocument()
|
||||||
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10)))
|
||||||
|
let configuredColor = NSColor(
|
||||||
|
calibratedRed: 0.88,
|
||||||
|
green: 0.18,
|
||||||
|
blue: 0.26,
|
||||||
|
alpha: 0.34
|
||||||
|
)
|
||||||
|
let insertion = try XCTUnwrap(
|
||||||
|
AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .comment,
|
||||||
|
comment: "",
|
||||||
|
author: "Professor",
|
||||||
|
commentColor: configuredColor
|
||||||
|
).first
|
||||||
|
)
|
||||||
|
|
||||||
|
try XCTAssertColor(insertion.annotation.color, equals: configuredColor)
|
||||||
|
XCTAssertEqual(
|
||||||
|
insertion.annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String,
|
||||||
|
AnnotationKeys.appKindComment
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testSelectionBoundCommentRoundTripsAsCommentKind() throws {
|
func testSelectionBoundCommentRoundTripsAsCommentKind() throws {
|
||||||
let document = try makeSelectableTextDocument()
|
let document = try makeSelectableTextDocument()
|
||||||
let page = try XCTUnwrap(document.page(at: 0))
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
@@ -205,6 +296,108 @@ final class AnnotationFactoryTests: XCTestCase {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPreviewCompatibleExportKeepsMarkupCommentWithoutPopupAnnotation() throws {
|
||||||
|
let document = try makeSelectableTextDocument()
|
||||||
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
||||||
|
let insertion = try XCTUnwrap(
|
||||||
|
AnnotationFactory.markupInsertions(
|
||||||
|
from: selection,
|
||||||
|
style: .highlight,
|
||||||
|
comment: "Preview should show this comment.",
|
||||||
|
author: "Professor"
|
||||||
|
).first
|
||||||
|
)
|
||||||
|
|
||||||
|
page.addAnnotation(insertion.annotation)
|
||||||
|
if let popup = insertion.popup {
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(AnnotationFactory.prepareForPreviewCompatibleExport(insertion.annotation, on: page))
|
||||||
|
XCTAssertNil(insertion.annotation.popup)
|
||||||
|
XCTAssertEqual(insertion.annotation.contents, "Preview should show this comment.")
|
||||||
|
XCTAssertFalse(page.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .popup)
|
||||||
|
})
|
||||||
|
|
||||||
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
||||||
|
let highlights = reopenedPage.annotations.filter {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(highlights.count, 1)
|
||||||
|
XCTAssertEqual(highlights.first?.contents, "Preview should show this comment.")
|
||||||
|
XCTAssertFalse(reopenedPage.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .popup)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewCompatibleExportRecoversPopupOnlyCommentText() throws {
|
||||||
|
let document = try makeSelectableTextDocument()
|
||||||
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
||||||
|
let bounds = selection.bounds(for: page)
|
||||||
|
let annotation = PDFAnnotation(bounds: bounds, forType: .highlight, withProperties: nil)
|
||||||
|
annotation.markupType = .highlight
|
||||||
|
annotation.color = AcademicAnnotationPalette.highlight
|
||||||
|
annotation.userName = "Professor"
|
||||||
|
annotation.quadrilateralPoints = [
|
||||||
|
NSValue(point: CGPoint(x: 0, y: bounds.height)),
|
||||||
|
NSValue(point: CGPoint(x: bounds.width, y: bounds.height)),
|
||||||
|
NSValue(point: .zero),
|
||||||
|
NSValue(point: CGPoint(x: bounds.width, y: 0))
|
||||||
|
]
|
||||||
|
let popup = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 360, y: 620, width: 220, height: 90),
|
||||||
|
forType: .popup,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
popup.contents = "Popup-only comment from another reader."
|
||||||
|
annotation.popup = popup
|
||||||
|
|
||||||
|
page.addAnnotation(annotation)
|
||||||
|
page.addAnnotation(popup)
|
||||||
|
|
||||||
|
XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Popup-only comment from another reader.")
|
||||||
|
XCTAssertTrue(AnnotationFactory.prepareForPreviewCompatibleExport(annotation, on: page))
|
||||||
|
XCTAssertNil(annotation.popup)
|
||||||
|
XCTAssertEqual(annotation.contents, "Popup-only comment from another reader.")
|
||||||
|
XCTAssertFalse(page.annotations.contains {
|
||||||
|
AnnotationKeys.annotation($0, hasSubtype: .popup)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyAppCommentTextFallsBackToStandardContents() throws {
|
||||||
|
let annotation = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 72, y: 620, width: 260, height: 24),
|
||||||
|
forType: .highlight,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
AnnotationKeys.setCommentText("", for: annotation)
|
||||||
|
annotation.contents = "Comment added by another PDF reader."
|
||||||
|
|
||||||
|
XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Comment added by another PDF reader.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyAppCommentTextFallsBackToPopupContents() throws {
|
||||||
|
let annotation = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 72, y: 620, width: 260, height: 24),
|
||||||
|
forType: .highlight,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
let popup = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 360, y: 620, width: 220, height: 90),
|
||||||
|
forType: .popup,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
AnnotationKeys.setCommentText("", for: annotation)
|
||||||
|
popup.contents = "Popup comment added by another PDF reader."
|
||||||
|
annotation.popup = popup
|
||||||
|
|
||||||
|
XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Popup comment added by another PDF reader.")
|
||||||
|
}
|
||||||
|
|
||||||
func testAddingAnnotationPreservesPriorAnnotation() throws {
|
func testAddingAnnotationPreservesPriorAnnotation() throws {
|
||||||
let document = try makeSelectableTextDocument()
|
let document = try makeSelectableTextDocument()
|
||||||
let page = try XCTUnwrap(document.page(at: 0))
|
let page = try XCTUnwrap(document.page(at: 0))
|
||||||
@@ -379,6 +572,66 @@ final class AnnotationFactoryTests: XCTestCase {
|
|||||||
XCTAssertEqual(replySnapshot.parentID, parentSnapshot.id)
|
XCTAssertEqual(replySnapshot.parentID, parentSnapshot.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUnresolvedStringReplyParentIDStaysVisibleAsTopLevelReply() throws {
|
||||||
|
let document = PDFDocument()
|
||||||
|
let page = PDFPage()
|
||||||
|
document.insert(page, at: 0)
|
||||||
|
|
||||||
|
let orphanedReply = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 100, y: 100, width: 24, height: 24),
|
||||||
|
forType: .text,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
AnnotationFactory.standardize(
|
||||||
|
orphanedReply,
|
||||||
|
comment: "Reply from an external reader.",
|
||||||
|
author: "Reader",
|
||||||
|
date: Date()
|
||||||
|
)
|
||||||
|
_ = orphanedReply.setValue("missing-parent", forAnnotationKey: AnnotationKeys.inReplyTo)
|
||||||
|
_ = orphanedReply.setValue("R", forAnnotationKey: AnnotationKeys.replyType)
|
||||||
|
page.addAnnotation(orphanedReply)
|
||||||
|
|
||||||
|
let snapshot = try XCTUnwrap(AnnotationReader.snapshots(in: document).first)
|
||||||
|
XCTAssertEqual(snapshot.kind, .reply)
|
||||||
|
XCTAssertNil(snapshot.parentID)
|
||||||
|
XCTAssertFalse(snapshot.isReply)
|
||||||
|
XCTAssertEqual(snapshot.contents, "Reply from an external reader.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPageScopedSnapshotsOnlyReadRequestedPages() throws {
|
||||||
|
let document = PDFDocument()
|
||||||
|
let firstPage = PDFPage()
|
||||||
|
let secondPage = PDFPage()
|
||||||
|
let thirdPage = PDFPage()
|
||||||
|
document.insert(firstPage, at: 0)
|
||||||
|
document.insert(secondPage, at: 1)
|
||||||
|
document.insert(thirdPage, at: 2)
|
||||||
|
|
||||||
|
let firstAnnotation = AnnotationFactory.noteInsertion(
|
||||||
|
on: firstPage,
|
||||||
|
near: CGPoint(x: 100, y: 100),
|
||||||
|
comment: "First page note",
|
||||||
|
author: "Professor"
|
||||||
|
).annotation
|
||||||
|
firstPage.addAnnotation(firstAnnotation)
|
||||||
|
|
||||||
|
let thirdAnnotation = AnnotationFactory.noteInsertion(
|
||||||
|
on: thirdPage,
|
||||||
|
near: CGPoint(x: 200, y: 200),
|
||||||
|
comment: "Third page note",
|
||||||
|
author: "Professor"
|
||||||
|
).annotation
|
||||||
|
thirdPage.addAnnotation(thirdAnnotation)
|
||||||
|
|
||||||
|
let scopedSnapshots = AnnotationReader.snapshots(in: document, pages: [thirdPage, thirdPage])
|
||||||
|
|
||||||
|
XCTAssertEqual(scopedSnapshots.count, 1)
|
||||||
|
XCTAssertEqual(scopedSnapshots.first?.contents, "Third page note")
|
||||||
|
XCTAssertEqual(scopedSnapshots.first?.pageIndex, 2)
|
||||||
|
XCTAssertFalse(scopedSnapshots.contains { $0.annotation === firstAnnotation })
|
||||||
|
}
|
||||||
|
|
||||||
func testFreeTextCreatesStandardFreeTextAnnotation() throws {
|
func testFreeTextCreatesStandardFreeTextAnnotation() throws {
|
||||||
let page = PDFPage()
|
let page = PDFPage()
|
||||||
let insertion = AnnotationFactory.freeTextInsertion(
|
let insertion = AnnotationFactory.freeTextInsertion(
|
||||||
@@ -439,6 +692,35 @@ final class AnnotationFactoryTests: XCTestCase {
|
|||||||
try? FileManager.default.removeItem(at: outputURL)
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
return reopened
|
return reopened
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func XCTAssertColor(
|
||||||
|
_ actual: NSColor,
|
||||||
|
equals expected: NSColor,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) throws {
|
||||||
|
let actualComponents = try rgbaComponents(actual, file: file, line: line)
|
||||||
|
let expectedComponents = try rgbaComponents(expected, file: file, line: line)
|
||||||
|
|
||||||
|
XCTAssertEqual(actualComponents.red, expectedComponents.red, accuracy: 0.001, file: file, line: line)
|
||||||
|
XCTAssertEqual(actualComponents.green, expectedComponents.green, accuracy: 0.001, file: file, line: line)
|
||||||
|
XCTAssertEqual(actualComponents.blue, expectedComponents.blue, accuracy: 0.001, file: file, line: line)
|
||||||
|
XCTAssertEqual(actualComponents.alpha, expectedComponents.alpha, accuracy: 0.001, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rgbaComponents(
|
||||||
|
_ color: NSColor,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) throws -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
|
||||||
|
let rgb = try XCTUnwrap(color.usingColorSpace(.deviceRGB), file: file, line: line)
|
||||||
|
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 (red, green, blue, alpha)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Optional {
|
private extension Optional {
|
||||||
|
|||||||
38
Tests/IHatePDFsCoreTests/AnnotationHitTestingTests.swift
Normal file
38
Tests/IHatePDFsCoreTests/AnnotationHitTestingTests.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import XCTest
|
||||||
|
import PDFKit
|
||||||
|
@testable import IHatePDFsCore
|
||||||
|
|
||||||
|
final class AnnotationHitTestingTests: XCTestCase {
|
||||||
|
func testTextMarkupHitTestingUsesQuadPointsInsteadOfUnionBounds() {
|
||||||
|
let annotation = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 10, y: 20, width: 100, height: 60),
|
||||||
|
forType: .highlight,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
annotation.quadrilateralPoints = [
|
||||||
|
NSValue(point: CGPoint(x: 0, y: 55)),
|
||||||
|
NSValue(point: CGPoint(x: 100, y: 55)),
|
||||||
|
NSValue(point: CGPoint(x: 0, y: 45)),
|
||||||
|
NSValue(point: CGPoint(x: 100, y: 45)),
|
||||||
|
NSValue(point: CGPoint(x: 0, y: 15)),
|
||||||
|
NSValue(point: CGPoint(x: 100, y: 15)),
|
||||||
|
NSValue(point: CGPoint(x: 0, y: 5)),
|
||||||
|
NSValue(point: CGPoint(x: 100, y: 5))
|
||||||
|
]
|
||||||
|
|
||||||
|
XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 70), in: annotation))
|
||||||
|
XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 30), in: annotation))
|
||||||
|
XCTAssertFalse(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 50), in: annotation))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTextMarkupHitTestingFallsBackToBoundsWithoutQuadPoints() {
|
||||||
|
let annotation = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 10, y: 20, width: 100, height: 20),
|
||||||
|
forType: .underline,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 30), in: annotation))
|
||||||
|
XCTAssertFalse(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 60), in: annotation))
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Tests/IHatePDFsCoreTests/PDFFileSelectionTests.swift
Normal file
24
Tests/IHatePDFsCoreTests/PDFFileSelectionTests.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import IHatePDFsCore
|
||||||
|
|
||||||
|
final class PDFFileSelectionTests: XCTestCase {
|
||||||
|
func testPDFFileURLAcceptsPDFExtensionsCaseInsensitively() {
|
||||||
|
XCTAssertTrue(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/article.pdf")))
|
||||||
|
XCTAssertTrue(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/article.PDF")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPDFFileURLRejectsNonPDFAndRemoteURLs() {
|
||||||
|
XCTAssertFalse(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/notes.txt")))
|
||||||
|
XCTAssertFalse(PDFFileSelection.isPDFFileURL(URL(string: "https://example.com/article.pdf")!))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPDFFileURLRejectsDirectoriesNamedLikePDFs() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension("pdf")
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
XCTAssertFalse(PDFFileSelection.isPDFFileURL(directory))
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Tests/IHatePDFsCoreTests/ReturnKeyCommitPolicyTests.swift
Normal file
48
Tests/IHatePDFsCoreTests/ReturnKeyCommitPolicyTests.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import IHatePDFsCore
|
||||||
|
|
||||||
|
final class ReturnKeyCommitPolicyTests: XCTestCase {
|
||||||
|
func testPlainReturnCommitsInEditableMultilineText() {
|
||||||
|
XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit(
|
||||||
|
keyCode: 36,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
command: false,
|
||||||
|
control: false,
|
||||||
|
isEditableMultilineText: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeypadEnterCommitsInEditableMultilineText() {
|
||||||
|
XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit(
|
||||||
|
keyCode: 76,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
command: false,
|
||||||
|
control: false,
|
||||||
|
isEditableMultilineText: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShiftReturnDoesNotCommitSoTextViewCanInsertNewline() {
|
||||||
|
XCTAssertFalse(ReturnKeyCommitPolicy.shouldCommit(
|
||||||
|
keyCode: 36,
|
||||||
|
shift: true,
|
||||||
|
option: false,
|
||||||
|
command: false,
|
||||||
|
control: false,
|
||||||
|
isEditableMultilineText: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReturnDoesNotCommitOutsideEditableMultilineText() {
|
||||||
|
XCTAssertFalse(ReturnKeyCommitPolicy.shouldCommit(
|
||||||
|
keyCode: 36,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
command: false,
|
||||||
|
control: false,
|
||||||
|
isEditableMultilineText: false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
44
docs/APP_STORE.md
Normal file
44
docs/APP_STORE.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Mac App Store Release
|
||||||
|
|
||||||
|
Bundle ID: `net.akkolli.ihatepdfs`
|
||||||
|
|
||||||
|
Current App Store build values:
|
||||||
|
|
||||||
|
- `CFBundleShortVersionString`: `0.3.0`
|
||||||
|
- `CFBundleVersion`: `4`
|
||||||
|
- Privacy policy URL: `https://www.akkolli.net/ihatepdfs/privacy`
|
||||||
|
|
||||||
|
## Required Apple Developer Items
|
||||||
|
|
||||||
|
- An explicit macOS App ID for `net.akkolli.ihatepdfs`.
|
||||||
|
- An App Store provisioning profile for that App ID.
|
||||||
|
- An application signing certificate installed in Keychain, usually named `Apple Distribution: ...` or `3rd Party Mac Developer Application: ...`.
|
||||||
|
- An installer signing certificate installed in Keychain, usually named `3rd Party Mac Developer Installer: ...`. Apple may label this certificate type as Mac Installer Distribution in the developer portal or Xcode.
|
||||||
|
|
||||||
|
The app only needs these sandbox entitlements right now:
|
||||||
|
|
||||||
|
- `com.apple.security.app-sandbox`
|
||||||
|
- `com.apple.security.files.user-selected.read-write`
|
||||||
|
|
||||||
|
Do not add network, Apple Events, Downloads-folder, or bookmark entitlements unless the app gains a feature that requires them.
|
||||||
|
|
||||||
|
## Build The Upload Package
|
||||||
|
|
||||||
|
Download the App Store provisioning profile from Apple Developer, then run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
APP_SIGNING_IDENTITY="3rd Party Mac Developer Application: Your Name (TEAMID)" \
|
||||||
|
INSTALLER_SIGNING_IDENTITY="3rd Party Mac Developer Installer: Your Name (TEAMID)" \
|
||||||
|
PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \
|
||||||
|
scripts/make-app-store-pkg.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The package is written to `dist/IHatePDFs-v0.3-macos-appstore.pkg`.
|
||||||
|
|
||||||
|
The script derives the App Store application identifier and team identifier from the provisioning profile before signing. It also clears download quarantine metadata from the bundle before packaging, because App Store Connect rejects packages that contain quarantine extended attributes.
|
||||||
|
|
||||||
|
## Upload
|
||||||
|
|
||||||
|
Upload the `.pkg` with Transporter. You can also set `VALIDATE_WITH_ALTOOL=1` when running `scripts/make-app-store-pkg.sh` if you want the script to perform an `altool` validation after packaging. After App Store Connect processes the build, select it in the app version, finish metadata, answer App Privacy, fill review notes, and submit for review.
|
||||||
|
|
||||||
|
Keep `CFBundleShortVersionString` as `0.3.0` and `CFBundleVersion` as `4` for this upload. Increment `BUILD_NUMBER` in `scripts/release-version.sh` before uploading another build for the same version.
|
||||||
70
docs/ENGINEERING.md
Normal file
70
docs/ENGINEERING.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Engineering Principles
|
||||||
|
|
||||||
|
I Hate PDFs is intentionally a small native macOS app. Future work should preserve that constraint unless there is a documented, user-visible reason to do otherwise.
|
||||||
|
|
||||||
|
## Native First
|
||||||
|
|
||||||
|
- Build features with Swift, SwiftUI, AppKit, PDFKit, and other system frameworks that ship with macOS.
|
||||||
|
- Do not replace the app with Electron, Chromium, a web runtime, a bundled JavaScript app shell, or a cross-platform UI toolkit.
|
||||||
|
- Do not bundle a PDF renderer, OCR engine, database, scripting runtime, or large framework when a macOS system API can satisfy the requirement.
|
||||||
|
- Prefer native macOS controls and document behaviors over custom reimplementations when they meet the product need.
|
||||||
|
|
||||||
|
## Small By Default
|
||||||
|
|
||||||
|
Every change should aim for the smallest final app that still delivers the required fluidity, reliability, and functionality.
|
||||||
|
|
||||||
|
- Keep third-party dependencies at or near zero. Any new package must justify its shipped size, runtime cost, maintenance cost, and why system APIs are insufficient.
|
||||||
|
- Keep assets minimal. Avoid large raster images, fonts, sample PDFs, videos, model files, or generated resources in the app bundle.
|
||||||
|
- Keep build outputs out of source and releases unless they are intentional release artifacts.
|
||||||
|
- Prefer dynamic links to Apple system frameworks over vendored libraries.
|
||||||
|
- Avoid storing duplicate PDF data, rendered page caches, or annotation indexes unless profiling shows they are required for fluid interaction.
|
||||||
|
- Favor targeted updates over whole-document rescans for common interactions such as editing, replying, filtering, hovering, and sidebar refreshes.
|
||||||
|
|
||||||
|
## Size Budget
|
||||||
|
|
||||||
|
The release DMG should stay as small as practical. Treat size growth as a product regression, not just a packaging detail.
|
||||||
|
|
||||||
|
Before merging release-impacting work, compare:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build-app.sh
|
||||||
|
scripts/make-dmg.sh
|
||||||
|
du -sh "dist/I Hate PDFs.app" \
|
||||||
|
"dist/I Hate PDFs.app/Contents/MacOS/IHatePDFs" \
|
||||||
|
"dist/I Hate PDFs.app/Contents/Resources/AppIcon.icns" \
|
||||||
|
dist/IHatePDFs-v*-macos.dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
If a change materially increases the app bundle or DMG size, document why in the PR or commit notes. A useful rule of thumb: any dependency addition, bundled asset addition, or release-size increase above roughly 10% needs explicit justification.
|
||||||
|
|
||||||
|
## Performance Budget
|
||||||
|
|
||||||
|
Small size should not come at the expense of reader fluidity.
|
||||||
|
|
||||||
|
- Opening, scrolling, zooming, searching, annotating, saving, and sidebar navigation should remain responsive on long PDFs.
|
||||||
|
- Optimize around measured user workflows instead of speculative micro-optimizations.
|
||||||
|
- Keep expensive work page-scoped or lazy when possible.
|
||||||
|
- Use `swift test` plus the PDF verification scripts after behavior changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
swift test
|
||||||
|
swift scripts/verify-sample-pdf.swift
|
||||||
|
swift scripts/verify-pdf-annotations.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release Packaging
|
||||||
|
|
||||||
|
Release builds should use the existing lightweight packaging path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build-app.sh
|
||||||
|
scripts/make-dmg.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/build-app.sh` strips release binaries by default to reduce shipped size. Use `STRIP_RELEASE=0 scripts/build-app.sh` only when a symbol-rich release build is needed for debugging.
|
||||||
|
|
||||||
|
Universal `arm64` + `x86_64` builds are the default for public releases. Single-architecture builds are acceptable for local testing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ARCHS="" scripts/build-app.sh
|
||||||
|
```
|
||||||
62
docs/QA.md
62
docs/QA.md
@@ -15,20 +15,50 @@ Use at least:
|
|||||||
## App Workflow
|
## App Workflow
|
||||||
|
|
||||||
1. Open the PDF in I Hate PDFs.
|
1. Open the PDF in I Hate PDFs.
|
||||||
2. Select text and add a highlight.
|
2. Close the PDF, then drag a `.pdf` file onto the empty no-document window and verify it opens.
|
||||||
3. Add a comment to the highlight.
|
3. Open Settings from File > Settings... and with Command-, then verify highlight color, comment color, and opacity changes can be edited and reset.
|
||||||
4. Add an underline with a comment.
|
4. Select text and add a highlight; verify no comment popover opens.
|
||||||
5. Select text, right-click, and add a comment from the context menu.
|
5. Select text and add a comment; verify the comment color matches the Settings value.
|
||||||
6. Add free text directly on the page.
|
6. In the comment box, press Shift-Return and verify it inserts a new line, then press Return and verify the comment is saved.
|
||||||
7. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate.
|
7. Add an underline with a comment.
|
||||||
8. Quit and reopen the same PDF at the same approximate window width and verify the app restores that PDF's sidebar state; then open a different PDF and verify it starts in focused single-pane reading unless that document has its own saved state.
|
8. Select text, right-click, and add a comment from the context menu.
|
||||||
9. Add at least one reply and verify the comments sidebar presents the thread like a clean review/chat stream, with a visible connector line from the parent comment to the reply.
|
9. Add free text directly on the page.
|
||||||
10. Hover a comment row and verify the corresponding PDF text is highlighted; click both the parent comment text and the reply text in the sidebar and verify the PDF view navigates to and selects the corresponding annotation.
|
10. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate.
|
||||||
11. Verify highlights, comment markers, hidden page-level replies, and selected sidebar rows use muted native-feeling colors in light mode and do not visually overpower the document.
|
11. Quit and reopen the same PDF at the same approximate window width and verify the app restores that PDF's sidebar state; then open a different PDF and verify it starts in focused single-pane reading unless that document has its own saved state.
|
||||||
12. Switch the app to dark mode and verify the reading background, comments sidebar, editor popover, connector lines, selected rows, text fields, and annotation markers remain legible and restrained.
|
12. Add at least one reply and verify the comments sidebar presents the thread like a clean review/chat stream, with a visible connector line from the parent comment to the reply.
|
||||||
13. Save As an annotated copy.
|
13. Hover a comment row and verify the corresponding PDF text is highlighted; click both the parent comment text and the reply text in the sidebar and verify the PDF view navigates to and selects the corresponding annotation.
|
||||||
14. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain.
|
14. Click on commented text and underlined text and verify the comment popover opens; then click the line below or nearby whitespace and verify no popover opens.
|
||||||
15. Save over a disposable original and verify the overwrite warning appears.
|
15. Verify highlights, comment markers, hidden page-level replies, and selected sidebar rows use muted native-feeling colors in light mode and do not visually overpower the document.
|
||||||
|
16. Switch the app to dark mode and verify the reading background, comments sidebar, editor popover, connector lines, selected rows, text fields, and annotation markers remain legible and restrained.
|
||||||
|
17. Save As an annotated copy.
|
||||||
|
18. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain.
|
||||||
|
19. Save over a disposable original and verify the overwrite warning appears.
|
||||||
|
20. Add an annotation and verify the window shows the native macOS unsaved/edited document indicator until the PDF is saved.
|
||||||
|
21. Search for a word, close the search toolbar, and verify the match highlights disappear; repeat after opening a different PDF to confirm stale search highlights do not carry over.
|
||||||
|
22. Type an invalid page number and an out-of-range page number in the page field, and verify the app restores the current page number with a clear status message; also verify previous/next page controls disable at the first and last pages.
|
||||||
|
23. Apply comment filters or search text that hide every comment, verify the empty state offers Clear Filters, and verify page counts include visible replies.
|
||||||
|
24. Collapse a page group in the comments sidebar, search for a comment on that page, and verify the matching results are shown while the filter is active.
|
||||||
|
25. Start typing a sidebar reply, click Reply on a different comment, and verify the original draft remains until you send or cancel it.
|
||||||
|
26. Click one comment row, then click Reply on a different comment or reply, and verify the sidebar selection and PDF highlight move to the reply target.
|
||||||
|
27. Click one comment row, then click Edit or the review-status chip on a different row, and verify the sidebar selection and PDF highlight move to the edited or reviewed row.
|
||||||
|
28. Set a comments-sidebar filter and collapse a page group, then open another PDF and verify the comments sidebar starts unfiltered with page groups expanded.
|
||||||
|
29. In Settings, choose very low-opacity highlight and comment colors, add each annotation type, and verify saved annotations remain visibly readable.
|
||||||
|
30. Start typing a sidebar reply without sending it, then close or replace the PDF and verify the app asks before discarding the draft and the window shows the edited indicator while the draft exists.
|
||||||
|
31. Start typing a sidebar reply without sending it, choose Share, and verify the app warns that the draft will not be included unless it is sent first.
|
||||||
|
32. Start typing a sidebar reply, delete the comment thread it belongs to, and verify the app asks before discarding the reply draft.
|
||||||
|
33. Add replies to a comment, delete the parent comment from both the sidebar and the popover path in separate runs, and verify the whole thread is removed each time.
|
||||||
|
34. Hover a comment row until the matching PDF annotation highlights, then hide the comments sidebar or apply a filter that removes the row and verify the hover highlight clears.
|
||||||
|
35. Hover and click a sidebar reply, and verify the PDF scrolls to and highlights the visible parent annotation rather than jumping to a hidden reply marker.
|
||||||
|
36. Search for a word with matches, edit the search field without pressing Return, and verify old PDF match highlights clear and previous/next search buttons disable until the new query is submitted.
|
||||||
|
37. Start typing a sidebar reply without sending it, choose Save As, and verify the app warns that the draft will not be included unless it is sent first.
|
||||||
|
38. Create a new selected-text comment or free text, leave its popover empty, choose Save before closing the popover, and verify the temporary empty annotation is discarded instead of saved.
|
||||||
|
39. Start typing a sidebar reply with no other unsaved annotation changes and verify the status bar shows a reply draft instead of presenting the PDF as clean.
|
||||||
|
40. Search for a word with matches, step through results, and verify the status bar reports the current match position; then search for text that is not present and verify PDF match highlights clear.
|
||||||
|
41. Open comments search and verify the field is focused immediately; enter a search, hide the search controls, and verify the search icon still indicates an active hidden filter.
|
||||||
|
42. Select a comment row, apply a comments-sidebar filter or search that hides that row, and verify the PDF selection highlight clears instead of lingering on the page.
|
||||||
|
43. Create a new selected-text comment or free text, leave its popover empty, choose Share, and verify the temporary empty annotation is discarded before any Save and Share output is written.
|
||||||
|
44. Select a comment or annotation row, hide the only sidebar that shows that row, and verify the PDF selection highlight clears; repeat while the left Annotations sidebar is visible and verify the selection stays visible there.
|
||||||
|
45. Select a comment row, collapse its page group in the comments sidebar, and verify the PDF selection highlight clears; then search/filter comments and verify matching page groups expand while filtering.
|
||||||
|
|
||||||
## External Readers
|
## External Readers
|
||||||
|
|
||||||
@@ -39,7 +69,9 @@ swift scripts/verify-sample-pdf.swift
|
|||||||
swift scripts/verify-pdf-annotations.swift
|
swift scripts/verify-pdf-annotations.swift
|
||||||
```
|
```
|
||||||
|
|
||||||
These checks generate an annotated PDF, reopen it with PDFKit, and inspect the raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Popup`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries.
|
These checks generate an annotated PDF, reopen it with PDFKit, and inspect the raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries.
|
||||||
|
|
||||||
|
For Preview interoperability, exported markup comments should keep the comment text on the parent annotation's standard `/Contents` key and should not depend on PDFKit-generated `/Popup` links for highlights or underlines.
|
||||||
|
|
||||||
Open the saved annotated copy in:
|
Open the saved annotated copy in:
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ set -euo pipefail
|
|||||||
|
|
||||||
APP_NAME="I Hate PDFs"
|
APP_NAME="I Hate PDFs"
|
||||||
EXECUTABLE_NAME="IHatePDFs"
|
EXECUTABLE_NAME="IHatePDFs"
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
source "$ROOT_DIR/scripts/release-version.sh"
|
||||||
CONFIGURATION="${CONFIGURATION:-release}"
|
CONFIGURATION="${CONFIGURATION:-release}"
|
||||||
|
BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}"
|
||||||
|
STRIP_RELEASE="${STRIP_RELEASE:-1}"
|
||||||
|
SIGNING_IDENTITY="${SIGNING_IDENTITY:-}"
|
||||||
|
ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-}"
|
||||||
|
PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}"
|
||||||
|
CODESIGN_TIMESTAMP="${CODESIGN_TIMESTAMP:-1}"
|
||||||
|
CODESIGN_OPTIONS="${CODESIGN_OPTIONS:-}"
|
||||||
|
PLISTBUDDY="/usr/libexec/PlistBuddy"
|
||||||
if [[ -z "${ARCHS+x}" && "$CONFIGURATION" == "release" ]]; then
|
if [[ -z "${ARCHS+x}" && "$CONFIGURATION" == "release" ]]; then
|
||||||
ARCHS="arm64 x86_64"
|
ARCHS="arm64 x86_64"
|
||||||
else
|
else
|
||||||
ARCHS="${ARCHS:-}"
|
ARCHS="${ARCHS:-}"
|
||||||
fi
|
fi
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
DIST_DIR="$ROOT_DIR/dist"
|
DIST_DIR="$ROOT_DIR/dist"
|
||||||
APP_DIR="$DIST_DIR/$APP_NAME.app"
|
APP_DIR="$DIST_DIR/$APP_NAME.app"
|
||||||
CONTENTS_DIR="$APP_DIR/Contents"
|
CONTENTS_DIR="$APP_DIR/Contents"
|
||||||
@@ -17,6 +26,29 @@ MACOS_DIR="$CONTENTS_DIR/MacOS"
|
|||||||
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||||
ICON_SOURCE="$ROOT_DIR/ihatepdf.png"
|
ICON_SOURCE="$ROOT_DIR/ihatepdf.png"
|
||||||
ICON_NAME="AppIcon"
|
ICON_NAME="AppIcon"
|
||||||
|
DERIVED_ENTITLEMENTS_PATH=""
|
||||||
|
PROFILE_PLIST_PATH=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$DERIVED_ENTITLEMENTS_PATH" ]]; then
|
||||||
|
rm -f "$DERIVED_ENTITLEMENTS_PATH"
|
||||||
|
fi
|
||||||
|
if [[ -n "$PROFILE_PLIST_PATH" ]]; then
|
||||||
|
rm -f "$PROFILE_PLIST_PATH"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
set_plist_string() {
|
||||||
|
local plist="$1"
|
||||||
|
local key="$2"
|
||||||
|
local value="$3"
|
||||||
|
|
||||||
|
if "$PLISTBUDDY" -c "Set :$key $value" "$plist" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
"$PLISTBUDDY" -c "Add :$key string $value" "$plist"
|
||||||
|
}
|
||||||
|
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
SWIFT_BUILD_ARGS=(-c "$CONFIGURATION")
|
SWIFT_BUILD_ARGS=(-c "$CONFIGURATION")
|
||||||
@@ -24,13 +56,26 @@ for ARCH in $ARCHS; do
|
|||||||
SWIFT_BUILD_ARGS+=(--arch "$ARCH")
|
SWIFT_BUILD_ARGS+=(--arch "$ARCH")
|
||||||
done
|
done
|
||||||
|
|
||||||
swift build "${SWIFT_BUILD_ARGS[@]}"
|
|
||||||
BUILD_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)"
|
BUILD_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)"
|
||||||
|
swift build "${SWIFT_BUILD_ARGS[@]}"
|
||||||
|
|
||||||
rm -rf "$APP_DIR"
|
rm -rf "$APP_DIR"
|
||||||
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||||
cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME"
|
cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME"
|
||||||
|
|
||||||
|
if [[ "$CONFIGURATION" == "release" && "$STRIP_RELEASE" != "0" ]]; then
|
||||||
|
strip -x "$MACOS_DIR/$EXECUTABLE_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$PROVISIONING_PROFILE" ]]; then
|
||||||
|
if [[ ! -f "$PROVISIONING_PROFILE" ]]; then
|
||||||
|
echo "Missing provisioning profile: $PROVISIONING_PROFILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$PROVISIONING_PROFILE" "$CONTENTS_DIR/embedded.provisionprofile"
|
||||||
|
xattr -cr "$CONTENTS_DIR/embedded.provisionprofile" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$ICON_SOURCE" ]]; then
|
if [[ ! -f "$ICON_SOURCE" ]]; then
|
||||||
echo "Missing app icon source: $ICON_SOURCE" >&2
|
echo "Missing app icon source: $ICON_SOURCE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -70,7 +115,7 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
|||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$EXECUTABLE_NAME</string>
|
<string>$EXECUTABLE_NAME</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>org.ihatepdfs.app</string>
|
<string>$BUNDLE_ID</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
@@ -97,11 +142,13 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.0</string>
|
<string>$APP_VERSION</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$BUILD_NUMBER</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.productivity</string>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||||
@@ -114,4 +161,51 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
|||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|
||||||
|
if [[ -n "$SIGNING_IDENTITY" ]]; then
|
||||||
|
if [[ -n "$ENTITLEMENTS_PATH" && ! -f "$ENTITLEMENTS_PATH" ]]; then
|
||||||
|
echo "Missing entitlements file: $ENTITLEMENTS_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH"
|
||||||
|
if [[ -n "$PROVISIONING_PROFILE" ]]; then
|
||||||
|
PROFILE_PLIST_PATH="$(mktemp "$DIST_DIR/profile.XXXXXX.plist")"
|
||||||
|
security cms -D -i "$PROVISIONING_PROFILE" > "$PROFILE_PLIST_PATH"
|
||||||
|
APP_IDENTIFIER="$("$PLISTBUDDY" -c "Print :Entitlements:com.apple.application-identifier" "$PROFILE_PLIST_PATH")"
|
||||||
|
TEAM_IDENTIFIER="$("$PLISTBUDDY" -c "Print :Entitlements:com.apple.developer.team-identifier" "$PROFILE_PLIST_PATH")"
|
||||||
|
|
||||||
|
DERIVED_ENTITLEMENTS_PATH="$(mktemp "$DIST_DIR/entitlements.XXXXXX.plist")"
|
||||||
|
if [[ -n "$ENTITLEMENTS_PATH" ]]; then
|
||||||
|
cp "$ENTITLEMENTS_PATH" "$DERIVED_ENTITLEMENTS_PATH"
|
||||||
|
else
|
||||||
|
cat > "$DERIVED_ENTITLEMENTS_PATH" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
fi
|
||||||
|
|
||||||
|
set_plist_string "$DERIVED_ENTITLEMENTS_PATH" "com.apple.application-identifier" "$APP_IDENTIFIER"
|
||||||
|
set_plist_string "$DERIVED_ENTITLEMENTS_PATH" "com.apple.developer.team-identifier" "$TEAM_IDENTIFIER"
|
||||||
|
APP_ENTITLEMENTS_PATH="$DERIVED_ENTITLEMENTS_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CODESIGN_ARGS=(--force --sign "$SIGNING_IDENTITY")
|
||||||
|
if [[ "$CODESIGN_TIMESTAMP" != "0" ]]; then
|
||||||
|
CODESIGN_ARGS+=(--timestamp)
|
||||||
|
fi
|
||||||
|
if [[ -n "$CODESIGN_OPTIONS" ]]; then
|
||||||
|
CODESIGN_ARGS+=(--options "$CODESIGN_OPTIONS")
|
||||||
|
fi
|
||||||
|
if [[ -n "$APP_ENTITLEMENTS_PATH" ]]; then
|
||||||
|
CODESIGN_ARGS+=(--entitlements "$APP_ENTITLEMENTS_PATH")
|
||||||
|
fi
|
||||||
|
|
||||||
|
codesign "${CODESIGN_ARGS[@]}" "$APP_DIR"
|
||||||
|
codesign --verify --strict --verbose=2 "$APP_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Built $APP_DIR"
|
echo "Built $APP_DIR"
|
||||||
|
du -sh "$APP_DIR" "$MACOS_DIR/$EXECUTABLE_NAME" "$RESOURCES_DIR/$ICON_NAME.icns"
|
||||||
|
|||||||
69
scripts/make-app-store-pkg.sh
Executable file
69
scripts/make-app-store-pkg.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
source "$ROOT_DIR/scripts/release-version.sh"
|
||||||
|
|
||||||
|
APP_NAME="I Hate PDFs"
|
||||||
|
BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}"
|
||||||
|
APP_SIGNING_IDENTITY="${APP_SIGNING_IDENTITY:-}"
|
||||||
|
INSTALLER_SIGNING_IDENTITY="${INSTALLER_SIGNING_IDENTITY:-}"
|
||||||
|
PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}"
|
||||||
|
ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-$ROOT_DIR/Signing/IHatePDFs-AppStore.entitlements}"
|
||||||
|
DIST_DIR="$ROOT_DIR/dist"
|
||||||
|
APP_DIR="$DIST_DIR/$APP_NAME.app"
|
||||||
|
PKG_PATH="${PKG_PATH:-$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-appstore.pkg}"
|
||||||
|
VALIDATE_WITH_ALTOOL="${VALIDATE_WITH_ALTOOL:-0}"
|
||||||
|
|
||||||
|
require_value() {
|
||||||
|
local name="$1"
|
||||||
|
local value="$2"
|
||||||
|
local hint="$3"
|
||||||
|
|
||||||
|
if [[ -z "$value" ]]; then
|
||||||
|
echo "Missing $name." >&2
|
||||||
|
echo "$hint" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_value "APP_SIGNING_IDENTITY" "$APP_SIGNING_IDENTITY" \
|
||||||
|
"Example: APP_SIGNING_IDENTITY=\"Apple Distribution: Your Name (TEAMID)\" or \"3rd Party Mac Developer Application: Your Name (TEAMID)\""
|
||||||
|
require_value "INSTALLER_SIGNING_IDENTITY" "$INSTALLER_SIGNING_IDENTITY" \
|
||||||
|
"Example: INSTALLER_SIGNING_IDENTITY=\"3rd Party Mac Developer Installer: Your Name (TEAMID)\""
|
||||||
|
require_value "PROVISIONING_PROFILE" "$PROVISIONING_PROFILE" \
|
||||||
|
"Download an App Store provisioning profile for $BUNDLE_ID and pass its local path."
|
||||||
|
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
BUNDLE_ID="$BUNDLE_ID" \
|
||||||
|
APP_VERSION="$APP_VERSION" \
|
||||||
|
BUILD_NUMBER="$BUILD_NUMBER" \
|
||||||
|
SIGNING_IDENTITY="$APP_SIGNING_IDENTITY" \
|
||||||
|
ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH" \
|
||||||
|
PROVISIONING_PROFILE="$PROVISIONING_PROFILE" \
|
||||||
|
"$ROOT_DIR/scripts/build-app.sh"
|
||||||
|
|
||||||
|
xattr -cr "$APP_DIR" 2>/dev/null || true
|
||||||
|
rm -f "$PKG_PATH"
|
||||||
|
productbuild \
|
||||||
|
--component "$APP_DIR" /Applications \
|
||||||
|
--sign "$INSTALLER_SIGNING_IDENTITY" \
|
||||||
|
"$PKG_PATH"
|
||||||
|
|
||||||
|
pkgutil --check-signature "$PKG_PATH"
|
||||||
|
|
||||||
|
if [[ "$VALIDATE_WITH_ALTOOL" == "1" ]]; then
|
||||||
|
require_value "ASC_USERNAME" "${ASC_USERNAME:-}" \
|
||||||
|
"Set ASC_USERNAME to the Apple ID or App Store Connect API key issuer format expected by altool."
|
||||||
|
require_value "ASC_PASSWORD" "${ASC_PASSWORD:-}" \
|
||||||
|
"Set ASC_PASSWORD to an app-specific password or app-store-connect API key password."
|
||||||
|
|
||||||
|
xcrun altool --validate-app \
|
||||||
|
--type macos \
|
||||||
|
--file "$PKG_PATH" \
|
||||||
|
--username "$ASC_USERNAME" \
|
||||||
|
--password "$ASC_PASSWORD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created App Store package: $PKG_PATH"
|
||||||
@@ -2,13 +2,15 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
source "$ROOT_DIR/scripts/release-version.sh"
|
||||||
APP_NAME="I Hate PDFs"
|
APP_NAME="I Hate PDFs"
|
||||||
DIST_DIR="$ROOT_DIR/dist"
|
DIST_DIR="$ROOT_DIR/dist"
|
||||||
APP_DIR="$DIST_DIR/$APP_NAME.app"
|
APP_DIR="$DIST_DIR/$APP_NAME.app"
|
||||||
DMG_PATH="$DIST_DIR/IHatePDFs.dmg"
|
DMG_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos.dmg"
|
||||||
|
BUILD_APP="${BUILD_APP:-1}"
|
||||||
|
|
||||||
if [[ ! -d "$APP_DIR" ]]; then
|
if [[ "$BUILD_APP" != "0" || ! -d "$APP_DIR" ]]; then
|
||||||
"$ROOT_DIR/scripts/build-app.sh"
|
APP_VERSION="$APP_VERSION" BUILD_NUMBER="$BUILD_NUMBER" "$ROOT_DIR/scripts/build-app.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f "$DMG_PATH"
|
rm -f "$DMG_PATH"
|
||||||
|
|||||||
5
scripts/release-version.sh
Executable file
5
scripts/release-version.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
APP_VERSION="${APP_VERSION:-0.3.0}"
|
||||||
|
BUILD_NUMBER="${BUILD_NUMBER:-4}"
|
||||||
|
RELEASE_VERSION="${RELEASE_VERSION:-${APP_VERSION%.0}}"
|
||||||
@@ -18,8 +18,9 @@ enum VerificationError: Error, CustomStringConvertible {
|
|||||||
case missingName(page: Int, index: Int, key: String)
|
case missingName(page: Int, index: Int, key: String)
|
||||||
case missingString(page: Int, index: Int, key: String)
|
case missingString(page: Int, index: Int, key: String)
|
||||||
case missingArray(page: Int, index: Int, key: String)
|
case missingArray(page: Int, index: Int, key: String)
|
||||||
case missingPopup(page: Int, index: Int, subtype: String)
|
|
||||||
case missingPopupParent(page: Int, index: Int)
|
case missingPopupParent(page: Int, index: Int)
|
||||||
|
case unexpectedMarkupPopup(page: Int, index: Int, subtype: String)
|
||||||
|
case unexpectedPopupLink(page: Int, index: Int, subtype: String)
|
||||||
case missingExpectedSubtype(String)
|
case missingExpectedSubtype(String)
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
@@ -36,10 +37,12 @@ enum VerificationError: Error, CustomStringConvertible {
|
|||||||
return "Annotation \(index) on page \(page) is missing string key /\(key)"
|
return "Annotation \(index) on page \(page) is missing string key /\(key)"
|
||||||
case .missingArray(let page, let index, let key):
|
case .missingArray(let page, let index, let key):
|
||||||
return "Annotation \(index) on page \(page) is missing array key /\(key)"
|
return "Annotation \(index) on page \(page) is missing array key /\(key)"
|
||||||
case .missingPopup(let page, let index, let subtype):
|
|
||||||
return "\(subtype) annotation \(index) on page \(page) is missing a /Popup dictionary"
|
|
||||||
case .missingPopupParent(let page, let index):
|
case .missingPopupParent(let page, let index):
|
||||||
return "Popup annotation \(index) on page \(page) is missing a /Parent dictionary"
|
return "Popup annotation \(index) on page \(page) is missing a /Parent dictionary"
|
||||||
|
case .unexpectedMarkupPopup(let page, let index, let subtype):
|
||||||
|
return "Popup annotation \(index) on page \(page) points at a /\(subtype) markup annotation; markup comments should export through /Contents"
|
||||||
|
case .unexpectedPopupLink(let page, let index, let subtype):
|
||||||
|
return "\(subtype) annotation \(index) on page \(page) should store comments in /Contents, not a /Popup link"
|
||||||
case .missingExpectedSubtype(let subtype):
|
case .missingExpectedSubtype(let subtype):
|
||||||
return "Expected at least one /\(subtype) annotation"
|
return "Expected at least one /\(subtype) annotation"
|
||||||
}
|
}
|
||||||
@@ -85,7 +88,7 @@ for pageNumber in 1...document.numberOfPages {
|
|||||||
switch subtype {
|
switch subtype {
|
||||||
case "Highlight":
|
case "Highlight":
|
||||||
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
||||||
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Highlight")
|
try rejectPopupLink(in: annotation, subtype: subtype, page: pageNumber, index: annotationIndex)
|
||||||
if hasString(in: annotation, key: "IHatePDFsKind") {
|
if hasString(in: annotation, key: "IHatePDFsKind") {
|
||||||
try requireString(in: annotation, key: "IHatePDFsKind", page: pageNumber, index: annotationIndex)
|
try requireString(in: annotation, key: "IHatePDFsKind", page: pageNumber, index: annotationIndex)
|
||||||
summary.selectedTextComments += 1
|
summary.selectedTextComments += 1
|
||||||
@@ -95,7 +98,7 @@ for pageNumber in 1...document.numberOfPages {
|
|||||||
case "Underline":
|
case "Underline":
|
||||||
summary.underlines += 1
|
summary.underlines += 1
|
||||||
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
||||||
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Underline")
|
try rejectPopupLink(in: annotation, subtype: subtype, page: pageNumber, index: annotationIndex)
|
||||||
case "Text":
|
case "Text":
|
||||||
try requireTextKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
try requireTextKeys(in: annotation, page: pageNumber, index: annotationIndex)
|
||||||
|
|
||||||
@@ -104,7 +107,6 @@ for pageNumber in 1...document.numberOfPages {
|
|||||||
try requireString(in: annotation, key: "IRT", page: pageNumber, index: annotationIndex)
|
try requireString(in: annotation, key: "IRT", page: pageNumber, index: annotationIndex)
|
||||||
try requireString(in: annotation, key: "RT", page: pageNumber, index: annotationIndex)
|
try requireString(in: annotation, key: "RT", page: pageNumber, index: annotationIndex)
|
||||||
} else {
|
} else {
|
||||||
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Text")
|
|
||||||
summary.textNotes += 1
|
summary.textNotes += 1
|
||||||
}
|
}
|
||||||
case "FreeText":
|
case "FreeText":
|
||||||
@@ -118,9 +120,24 @@ for pageNumber in 1...document.numberOfPages {
|
|||||||
case "Popup":
|
case "Popup":
|
||||||
summary.popups += 1
|
summary.popups += 1
|
||||||
var parentDictionary: CGPDFDictionaryRef?
|
var parentDictionary: CGPDFDictionaryRef?
|
||||||
guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary) else {
|
guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary),
|
||||||
|
let parentDictionary
|
||||||
|
else {
|
||||||
throw VerificationError.missingPopupParent(page: pageNumber, index: annotationIndex)
|
throw VerificationError.missingPopupParent(page: pageNumber, index: annotationIndex)
|
||||||
}
|
}
|
||||||
|
let parentSubtype = try nameValue(
|
||||||
|
in: parentDictionary,
|
||||||
|
key: "Subtype",
|
||||||
|
page: pageNumber,
|
||||||
|
index: annotationIndex
|
||||||
|
)
|
||||||
|
if parentSubtype == "Highlight" || parentSubtype == "Underline" {
|
||||||
|
throw VerificationError.unexpectedMarkupPopup(
|
||||||
|
page: pageNumber,
|
||||||
|
index: annotationIndex,
|
||||||
|
subtype: parentSubtype
|
||||||
|
)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -145,10 +162,6 @@ guard summary.replies > 0 else {
|
|||||||
guard summary.freeText > 0 else {
|
guard summary.freeText > 0 else {
|
||||||
throw VerificationError.missingExpectedSubtype("FreeText")
|
throw VerificationError.missingExpectedSubtype("FreeText")
|
||||||
}
|
}
|
||||||
guard summary.popups >= 4 else {
|
|
||||||
throw VerificationError.missingExpectedSubtype("Popup")
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Verified raw PDF annotation dictionaries in \(inputURL.path): \(summary.highlights) highlight, \(summary.selectedTextComments) selected-text comment, \(summary.underlines) underline, \(summary.textNotes) text note, \(summary.replies) reply, \(summary.freeText) free-text, \(summary.popups) popups.")
|
print("Verified raw PDF annotation dictionaries in \(inputURL.path): \(summary.highlights) highlight, \(summary.selectedTextComments) selected-text comment, \(summary.underlines) underline, \(summary.textNotes) text note, \(summary.replies) reply, \(summary.freeText) free-text, \(summary.popups) popups.")
|
||||||
|
|
||||||
func annotationDictionary(
|
func annotationDictionary(
|
||||||
@@ -251,6 +264,20 @@ func requireMarkupKeys(
|
|||||||
try requireString(in: dictionary, key: "M", page: page, index: index)
|
try requireString(in: dictionary, key: "M", page: page, index: index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rejectPopupLink(
|
||||||
|
in dictionary: CGPDFDictionaryRef,
|
||||||
|
subtype: String,
|
||||||
|
page: Int,
|
||||||
|
index: Int
|
||||||
|
) throws {
|
||||||
|
var popupDictionary: CGPDFDictionaryRef?
|
||||||
|
guard CGPDFDictionaryGetDictionary(dictionary, "Popup", &popupDictionary) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw VerificationError.unexpectedPopupLink(page: page, index: index, subtype: subtype)
|
||||||
|
}
|
||||||
|
|
||||||
func requireTextKeys(
|
func requireTextKeys(
|
||||||
in dictionary: CGPDFDictionaryRef,
|
in dictionary: CGPDFDictionaryRef,
|
||||||
page: Int,
|
page: Int,
|
||||||
@@ -262,22 +289,3 @@ func requireTextKeys(
|
|||||||
try requireString(in: dictionary, key: "T", page: page, index: index)
|
try requireString(in: dictionary, key: "T", page: page, index: index)
|
||||||
try requireString(in: dictionary, key: "M", page: page, index: index)
|
try requireString(in: dictionary, key: "M", page: page, index: index)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requirePopup(
|
|
||||||
in dictionary: CGPDFDictionaryRef,
|
|
||||||
page: Int,
|
|
||||||
index: Int,
|
|
||||||
subtype: String
|
|
||||||
) throws {
|
|
||||||
var popupDictionary: CGPDFDictionaryRef?
|
|
||||||
guard CGPDFDictionaryGetDictionary(dictionary, "Popup", &popupDictionary),
|
|
||||||
let popupDictionary
|
|
||||||
else {
|
|
||||||
throw VerificationError.missingPopup(page: page, index: index, subtype: subtype)
|
|
||||||
}
|
|
||||||
|
|
||||||
let popupSubtype = try nameValue(in: popupDictionary, key: "Subtype", page: page, index: index)
|
|
||||||
guard popupSubtype == "Popup" else {
|
|
||||||
throw VerificationError.missingPopup(page: page, index: index, subtype: subtype)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ standardize(
|
|||||||
author: "Professor"
|
author: "Professor"
|
||||||
)
|
)
|
||||||
page.addAnnotation(highlight)
|
page.addAnnotation(highlight)
|
||||||
addPopup(for: highlight, bounds: CGRect(x: 352, y: 592, width: 240, height: 110))
|
|
||||||
|
|
||||||
let selectedTextComment = PDFAnnotation(
|
let selectedTextComment = PDFAnnotation(
|
||||||
bounds: CGRect(x: 72, y: 594, width: 260, height: 22),
|
bounds: CGRect(x: 72, y: 594, width: 260, height: 22),
|
||||||
@@ -42,12 +41,11 @@ selectedTextComment.quadrilateralPoints = quadPoints(width: 260, height: 22)
|
|||||||
standardize(
|
standardize(
|
||||||
selectedTextComment,
|
selectedTextComment,
|
||||||
name: "verify-selected-text-comment",
|
name: "verify-selected-text-comment",
|
||||||
contents: "This selected-text comment is saved as standard PDF markup with popup contents.",
|
contents: "This selected-text comment is saved as standard parent annotation contents.",
|
||||||
author: "Professor"
|
author: "Professor"
|
||||||
)
|
)
|
||||||
_ = selectedTextComment.setValue("Comment", forAnnotationKey: PDFAnnotationKey(rawValue: "IHatePDFsKind"))
|
_ = selectedTextComment.setValue("Comment", forAnnotationKey: PDFAnnotationKey(rawValue: "IHatePDFsKind"))
|
||||||
page.addAnnotation(selectedTextComment)
|
page.addAnnotation(selectedTextComment)
|
||||||
addPopup(for: selectedTextComment, bounds: CGRect(x: 352, y: 472, width: 240, height: 110))
|
|
||||||
|
|
||||||
let underline = PDFAnnotation(
|
let underline = PDFAnnotation(
|
||||||
bounds: CGRect(x: 72, y: 570, width: 260, height: 24),
|
bounds: CGRect(x: 72, y: 570, width: 260, height: 24),
|
||||||
@@ -64,7 +62,6 @@ standardize(
|
|||||||
author: "Professor"
|
author: "Professor"
|
||||||
)
|
)
|
||||||
page.addAnnotation(underline)
|
page.addAnnotation(underline)
|
||||||
addPopup(for: underline, bounds: CGRect(x: 352, y: 540, width: 240, height: 110))
|
|
||||||
|
|
||||||
let textNote = PDFAnnotation(
|
let textNote = PDFAnnotation(
|
||||||
bounds: CGRect(x: 360, y: 620, width: 28, height: 28),
|
bounds: CGRect(x: 360, y: 620, width: 28, height: 28),
|
||||||
@@ -153,18 +150,6 @@ func standardize(
|
|||||||
_ = annotation.setValue("Unmarked", forAnnotationKey: PDFAnnotationKey(rawValue: "State"))
|
_ = annotation.setValue("Unmarked", forAnnotationKey: PDFAnnotationKey(rawValue: "State"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func addPopup(for annotation: PDFAnnotation, bounds: CGRect) {
|
|
||||||
let popup = PDFAnnotation(bounds: bounds, forType: .popup, withProperties: nil)
|
|
||||||
popup.contents = annotation.contents
|
|
||||||
popup.userName = annotation.userName
|
|
||||||
popup.modificationDate = annotation.modificationDate
|
|
||||||
popup.isOpen = false
|
|
||||||
popup.shouldDisplay = true
|
|
||||||
popup.shouldPrint = true
|
|
||||||
annotation.popup = popup
|
|
||||||
page.addAnnotation(popup)
|
|
||||||
}
|
|
||||||
|
|
||||||
func quadPoints(width: CGFloat, height: CGFloat) -> [NSValue] {
|
func quadPoints(width: CGFloat, height: CGFloat) -> [NSValue] {
|
||||||
[
|
[
|
||||||
NSValue(point: CGPoint(x: 0, y: height)),
|
NSValue(point: CGPoint(x: 0, y: height)),
|
||||||
|
|||||||
Reference in New Issue
Block a user