Files
ihatepdfs/Sources/IHatePDFs/SidebarViews.swift
Akshay Kolli 085d7a16dc Release v0.3
2026-06-24 17:51:26 -07:00

828 lines
29 KiB
Swift

import IHatePDFsCore
import SwiftUI
struct LeftSidebarView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
VStack(spacing: 0) {
Picker("Sidebar", selection: $appState.sidebarMode) {
Text("Pages").tag(SidebarMode.pages)
Text("Annotations").tag(SidebarMode.annotations)
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(8)
Divider()
switch appState.sidebarMode {
case .pages:
PDFThumbnailRepresentedView()
.padding(.vertical, 6)
case .annotations:
AnnotationListView()
}
}
.background(.bar)
}
}
private struct AnnotationListView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
List(appState.annotations, selection: $appState.selectedAnnotationID) { item in
Button {
appState.select(item)
} label: {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.kind.symbolName)
.frame(width: 18)
.foregroundStyle(iconColor(for: item.kind))
.help(item.kind.displayName)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(item.kind.displayName)
.font(.caption.weight(.semibold))
Spacer()
Text("p. \(item.pageLabel)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(item.firstLine)
.font(.caption)
.foregroundStyle(item.hasComment ? .primary : .secondary)
.lineLimit(2)
Text(item.author)
.font(.caption2)
.foregroundStyle(.secondary)
Text(dateString(item.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
.listStyle(.sidebar)
}
private func iconColor(for kind: AcademicAnnotationKind) -> Color {
switch kind {
case .comment, .highlight, .note:
return Color(nsColor: .secondaryLabelColor)
case .underline, .reply:
return Color(nsColor: .tertiaryLabelColor)
case .freeText:
return Color(nsColor: .labelColor)
case .other:
return Color(nsColor: .tertiaryLabelColor)
}
}
private func dateString(_ date: Date?) -> String {
guard let date else { return "No date" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}
struct CommentsReviewSidebar: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@State private var showsSearch = false
@State private var showsFilters = false
@State private var showsAdvancedFilters = false
@FocusState private var isCommentSearchFocused: Bool
private var groupedComments: [(pageIndex: Int, items: [AnnotationSnapshot])] {
let grouped = Dictionary(grouping: appState.topLevelComments, by: \.pageIndex)
return grouped
.map { (pageIndex: $0.key, items: $0.value) }
.sorted { $0.pageIndex < $1.pageIndex }
}
private var visibleCommentCount: Int {
appState.topLevelComments.reduce(0) { partial, item in
partial + 1 + (appState.repliesByParent[item.id]?.count ?? 0)
}
}
private var isFilteringComments: Bool {
hasActiveCommentSearch || hasActiveCommentFilters
}
private var hasActiveCommentSearch: Bool {
!appState.commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var hasActiveCommentFilters: Bool {
appState.commentFilter != .all
|| appState.selectedKindFilter != nil
|| appState.selectedAuthorFilter != "All Authors"
|| appState.selectedStatusFilter != ReviewState.allStatuses
}
var body: some View {
VStack(spacing: 0) {
header
Divider()
quickComment
if showsSearch || showsFilters {
Divider()
filters
}
Divider()
commentList
}
.background(.bar)
}
private var header: some View {
HStack(spacing: 9) {
Image(systemName: "text.bubble.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Comments")
Text("Comments")
.font(.headline)
.lineLimit(1)
Text("\(visibleCommentCount)")
.font(.headline.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
showsSearch.toggle()
if showsSearch {
focusCommentSearch()
} else {
isCommentSearchFocused = false
}
} label: {
Label(
"Search Comments",
systemImage: (showsSearch || hasActiveCommentSearch) ? "magnifyingglass.circle.fill" : "magnifyingglass"
)
}
.labelStyle(.iconOnly)
.foregroundStyle(hasActiveCommentSearch ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
.help("Search Comments")
Button {
showsFilters.toggle()
} label: {
Label(
"Filter Comments",
systemImage: (showsFilters || hasActiveCommentFilters) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"
)
}
.labelStyle(.iconOnly)
.foregroundStyle(hasActiveCommentFilters ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
.help("Filter Comments")
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
}
private var quickComment: some View {
Button {
appState.addComment()
} label: {
HStack(spacing: 8) {
Image(systemName: "text.bubble")
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Comment on selected text")
Text("On selected text")
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Spacer()
}
.font(.callout)
.padding(.horizontal, 10)
.frame(height: 36)
.background(InterfacePalette.subtleFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(!appState.hasTextSelection)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.help("Select text, then add a comment")
}
private var filters: some View {
VStack(spacing: 8) {
if showsSearch {
TextField("Search comments", text: $appState.commentSearchText)
.textFieldStyle(.roundedBorder)
.focused($isCommentSearchFocused)
.onAppear {
focusCommentSearch()
}
}
if showsFilters {
Picker("Comment filter", selection: $appState.commentFilter) {
ForEach(CommentFilter.allCases) { filter in
Text(filter.title).tag(filter)
}
}
.pickerStyle(.segmented)
.labelsHidden()
DisclosureGroup("More Filters", isExpanded: $showsAdvancedFilters) {
VStack(spacing: 8) {
Picker("Type", selection: Binding(
get: { appState.selectedKindFilter },
set: { appState.selectedKindFilter = $0 }
)) {
Text("All Types").tag(Optional<AcademicAnnotationKind>.none)
ForEach(AcademicAnnotationKind.allCases.filter { $0 != .other }) { kind in
Text(kind.displayName).tag(Optional(kind))
}
}
Picker("Author", selection: $appState.selectedAuthorFilter) {
ForEach(appState.authors, id: \.self) { author in
Text(author).tag(author)
}
}
Picker("Status", selection: $appState.selectedStatusFilter) {
ForEach(appState.statuses, id: \.self) { status in
Text(status).tag(status)
}
}
}
.labelsHidden()
.padding(.top, 4)
}
.font(.caption)
}
}
.padding(10)
}
private func focusCommentSearch() {
DispatchQueue.main.async {
isCommentSearchFocused = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isCommentSearchFocused = true
}
}
private var commentList: some View {
Group {
if groupedComments.isEmpty {
CommentsEmptyState(isFiltering: isFilteringComments)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(groupedComments, id: \.pageIndex) { group in
PageCommentGroup(
pageIndex: group.pageIndex,
items: group.items,
repliesByParent: appState.repliesByParent,
showsPageHeader: appState.pageCount > 1,
isFiltering: isFilteringComments
)
}
}
.padding(.vertical, 4)
}
}
}
}
}
private struct CommentsEmptyState: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let isFiltering: Bool
var body: some View {
VStack(spacing: 9) {
Image(systemName: isFiltering ? "line.3.horizontal.decrease.circle" : "text.bubble")
.font(.system(size: 28, weight: .regular))
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
Text(isFiltering ? "No matching comments" : "No comments yet")
.font(.callout.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
Text(isFiltering ? "Adjust the search or filters to show more comments." : "Select text in the PDF, then add a comment.")
.font(.caption)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if isFiltering {
Button {
appState.clearCommentFilters()
} label: {
Label("Clear Filters", systemImage: "xmark.circle")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(18)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct PageCommentGroup: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let pageIndex: Int
let items: [AnnotationSnapshot]
let repliesByParent: [String: [AnnotationSnapshot]]
let showsPageHeader: Bool
let isFiltering: Bool
private var isCollapsed: Bool {
showsPageHeader && !isFiltering && appState.collapsedPageIndexes.contains(pageIndex)
}
private var visibleItemCount: Int {
items.reduce(0) { partial, item in
partial + 1 + (repliesByParent[item.id]?.count ?? 0)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if showsPageHeader {
if isFiltering {
pageHeader
.help("Filtered results are expanded")
} else {
Button {
if isCollapsed {
appState.collapsedPageIndexes.remove(pageIndex)
} else {
appState.collapsedPageIndexes.insert(pageIndex)
}
} label: {
pageHeader
}
.buttonStyle(.plain)
.help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments")
}
}
if !isCollapsed {
ForEach(items) { item in
let replies = repliesByParent[item.id] ?? []
CommentRow(item: item, replies: replies)
.id(([item.sidebarRenderID] + replies.map(\.sidebarRenderID)).joined(separator: "|"))
}
}
}
}
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 {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let item: AnnotationSnapshot
let replies: [AnnotationSnapshot]
var body: some View {
ZStack(alignment: .topLeading) {
if !replies.isEmpty {
Rectangle()
.fill(InterfacePalette.connector(for: colorScheme))
.frame(width: 1)
.padding(.leading, 14)
.padding(.top, 30)
.padding(.bottom, 20)
}
VStack(alignment: .leading, spacing: 0) {
parentComment
ForEach(replies) { reply in
ReplyRow(item: reply, threadRoot: item)
.id(reply.sidebarRenderID)
}
if appState.sidebarReplyParentID == item.id {
SidebarReplyComposer(threadRoot: item)
.id("reply-composer-\(item.id)")
}
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.overlay(alignment: .bottom) {
Rectangle()
.fill(InterfacePalette.hairline(for: colorScheme))
.frame(height: 1)
}
}
private var parentComment: some View {
HStack(alignment: .top, spacing: 9) {
CommentMarker(symbolName: item.kind.symbolName, size: 28, font: .caption)
.padding(.top, 1)
.help(item.kind == .reply ? "Reply" : "Comment Thread")
VStack(alignment: .leading, spacing: 6) {
Button {
appState.select(item)
} label: {
commentSummary
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityAddTraits(.isButton)
.accessibilityAction {
appState.select(item)
}
metadataRow(for: item)
HStack(spacing: 12) {
Button("Edit") {
appState.edit(item)
}
Button("Reply") {
appState.beginSidebarReply(to: item, inThread: item)
}
Button("Delete", role: .destructive) {
appState.delete(item)
}
}
.font(.caption.weight(.medium))
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.actionText(for: colorScheme))
}
}
.padding(.vertical, 2)
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
.onHover { isHovered in
appState.setCommentHover(item, isHovered: isHovered)
}
}
private var commentSummary: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(item.author)
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(1)
Spacer()
Text(dateString(item.modifiedAt ?? item.createdAt))
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
}
if item.hasComment {
Text(item.contents)
.font(.callout)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.fixedSize(horizontal: false, vertical: true)
} else if replies.isEmpty {
Text("No comment text")
.font(.callout)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.fixedSize(horizontal: false, vertical: true)
}
}
}
private func metadataRow(for item: AnnotationSnapshot) -> some View {
HStack {
ReviewStatusChip(item: item)
}
}
private func dateString(_ date: Date?) -> String {
guard let date else { return "No date" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}
private extension AnnotationSnapshot {
var sidebarRenderID: String {
[
id,
author,
contents,
status,
String(modifiedAt?.timeIntervalSinceReferenceDate ?? 0),
String(describing: bounds.minX),
String(describing: bounds.minY)
].joined(separator: "|")
}
}
private struct CommentMarker: View {
@Environment(\.colorScheme) private var colorScheme
let symbolName: String
let size: CGFloat
let font: Font
var body: some View {
ZStack {
Circle()
.fill(InterfacePalette.markerFill(for: colorScheme))
Circle()
.stroke(InterfacePalette.markerStroke(for: colorScheme), lineWidth: 0.75)
Image(systemName: symbolName)
.font(font)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
}
.frame(width: size, height: size)
.background(.bar)
.clipShape(Circle())
}
}
private struct SidebarReplyComposer: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@FocusState private var isFocused: Bool
let threadRoot: AnnotationSnapshot
private let editorHorizontalInset: CGFloat = 7
private let editorVerticalInset: CGFloat = 6
var body: some View {
HStack(alignment: .top, spacing: 8) {
CommentMarker(symbolName: "arrowshape.turn.up.left", size: 22, font: .caption2)
.frame(width: 28, alignment: .center)
.padding(.top, 9)
.help("Reply")
VStack(alignment: .leading, spacing: 7) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("Reply")
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
if let target = appState.sidebarReplyTarget {
Text("to \(target.author)")
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
} else {
Text("to \(threadRoot.author)")
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
}
Spacer()
}
ZStack(alignment: .topLeading) {
TextEditor(text: $appState.sidebarReplyDraft)
.font(.callout)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.scrollContentBackground(.hidden)
.focused($isFocused)
.padding(.horizontal, editorHorizontalInset)
.padding(.vertical, editorVerticalInset)
if appState.sidebarReplyDraft.isEmpty {
Text("Write a reply")
.font(.callout)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.padding(.leading, editorHorizontalInset + 6)
.padding(.top, editorVerticalInset)
.allowsHitTesting(false)
}
}
.frame(minHeight: 76)
.background(InterfacePalette.fieldFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
HStack(spacing: 8) {
TextField("Author", text: $appState.sidebarReplyAuthor)
.textFieldStyle(.plain)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.padding(.horizontal, 7)
.frame(height: 26)
.background(InterfacePalette.fieldFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
Spacer()
Button("Cancel") {
appState.cancelSidebarReply()
}
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Button {
appState.commitSidebarReply()
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
.disabled(appState.sidebarReplyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.keyboardShortcut(.return, modifiers: [.command])
}
.font(.caption.weight(.medium))
}
}
.padding(.top, 9)
.padding(.bottom, 2)
.onAppear {
DispatchQueue.main.async {
isFocused = true
}
}
.commitOnPlainReturn {
if !appState.sidebarReplyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
appState.commitSidebarReply()
}
}
}
}
private struct ReviewStatusChip: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let item: AnnotationSnapshot
var body: some View {
Button {
appState.toggleReviewed(item)
} label: {
HStack(spacing: 4) {
if isReviewed {
Image(systemName: "checkmark")
.font(.caption2.weight(.bold))
}
Text(label)
.font(.caption2)
}
.foregroundStyle(foreground)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(background)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.help(isReviewed ? "Mark as not reviewed" : "Mark as reviewed")
}
private var isReviewed: Bool {
ReviewState.isReviewed(item.status)
}
private var label: String {
ReviewState.label(for: item.status)
}
private var foreground: Color {
isReviewed
? InterfacePalette.actionText(for: colorScheme)
: InterfacePalette.quietText(for: colorScheme)
}
private var background: Color {
if isReviewed {
return Color(nsColor: .controlAccentColor).opacity(colorScheme == .dark ? 0.16 : 0.11)
}
return InterfacePalette.subtleFill(for: colorScheme)
}
}
private struct ReplyRow: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let item: AnnotationSnapshot
let threadRoot: AnnotationSnapshot
var body: some View {
HStack(alignment: .top, spacing: 8) {
CommentMarker(symbolName: "text.bubble", size: 22, font: .caption2)
.frame(width: 28, alignment: .center)
.padding(.top, 7)
.help("Reply")
VStack(alignment: .leading, spacing: 4) {
Button {
appState.select(item)
} label: {
replySummary
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityAddTraits(.isButton)
.accessibilityAction {
appState.select(item)
}
replyMetadataRow
HStack(spacing: 12) {
Button("Edit") {
appState.edit(item)
}
Button("Reply") {
appState.beginSidebarReply(to: item, inThread: threadRoot)
}
Button("Delete", role: .destructive) {
appState.delete(item)
}
}
.font(.caption.weight(.medium))
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.actionText(for: colorScheme))
}
}
.padding(.top, 8)
.padding(.bottom, 2)
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
.onHover { isHovered in
appState.setCommentHover(item, isHovered: isHovered)
}
}
private var replySummary: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(item.author)
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(1)
Spacer()
Text(dateString(item.modifiedAt ?? item.createdAt))
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
}
Text(item.contents.isEmpty ? "No reply text" : item.contents)
.font(.caption)
.foregroundStyle(item.contents.isEmpty ? InterfacePalette.quietText(for: colorScheme) : InterfacePalette.primaryText(for: colorScheme))
.fixedSize(horizontal: false, vertical: true)
}
}
private var replyMetadataRow: some View {
HStack {
ReviewStatusChip(item: item)
}
}
private func dateString(_ date: Date?) -> String {
guard let date else { return "No date" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}