Files
ihatepdfs/sources/app/SidebarViews.swift
2026-06-30 10:47:14 -07:00

1360 lines
48 KiB
Swift

import AppKit
import IHatePDFsCore
import SwiftUI
struct LeftSidebarView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
VStack(spacing: 0) {
LeftSidebarModeSwitcher(selection: $appState.leftSidebarMode)
.padding(.horizontal, 10)
.padding(.vertical, 8)
Divider()
switch appState.leftSidebarMode {
case .pages:
PDFThumbnailRepresentedView()
.padding(.vertical, 6)
case .annotations:
AnnotationNavigationListView()
}
}
.background(.bar)
}
}
private struct LeftSidebarModeSwitcher: View {
@Binding var selection: LeftSidebarMode
var body: some View {
Picker("Left Sidebar", selection: $selection) {
Text("Pages").tag(LeftSidebarMode.pages)
Text("Marks").tag(LeftSidebarMode.annotations)
}
.pickerStyle(.segmented)
.controlSize(.small)
.padding(.vertical, 7)
.padding(.horizontal, 6)
.labelsHidden()
}
}
private struct AnnotationNavigationListView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
VStack(spacing: 0) {
SidebarSectionHeader(
title: "Annotations",
count: appState.annotations.count,
systemImage: "list.bullet.rectangle"
)
Divider()
if appState.annotations.isEmpty {
SidebarEmptyState(
systemImage: "list.bullet.rectangle",
title: "No annotations",
message: "Highlights, underlines, and comments will appear here."
)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(appState.annotations) { item in
AnnotationNavigationRow(item: item)
}
}
.padding(.vertical, 4)
}
}
}
}
}
private struct AnnotationNavigationRow: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let item: AnnotationSnapshot
private var previewText: String {
if item.kind == .highlight, !item.hasComment {
return item.highlightExcerpt
}
if item.kind == .reply {
return item.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Reply"
: item.contents
}
return item.firstLine
}
private var submetadataText: String {
var parts = ["Page \(item.pageLabel)"]
if !item.author.isEmpty {
parts.append(item.author)
}
return parts.joined(separator: " - ")
}
private var dateText: String? {
let date = item.modifiedAt ?? item.createdAt
return date?.formatted(date: .abbreviated, time: .omitted)
}
var body: some View {
Button {
appState.select(item)
} label: {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.kind.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(item.id == appState.selectedAnnotationID ? Color.accentColor : InterfacePalette.secondaryText(for: colorScheme))
.frame(width: 16, height: 18)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(item.kind.displayName)
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(1)
Spacer(minLength: 4)
if let dateText {
Text(dateText)
.font(.caption2)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.lineLimit(1)
}
}
Text(submetadataText)
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
Text(previewText)
.font(.caption)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
}
.buttonStyle(.plain)
.help("Go to \(item.kind.displayName.lowercased()) on page \(item.pageLabel)")
.onHover { isHovered in
appState.setCommentHover(item, isHovered: isHovered)
}
}
}
struct RightSidebarView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
VStack(spacing: 0) {
SidebarModeSwitcher(selection: $appState.sidebarMode)
.padding(.horizontal, 12)
.padding(.vertical, 10)
Divider()
switch appState.sidebarMode {
case .annotations, .pages:
CommentsReviewSidebar()
case .highlights:
HighlightedTextListView()
}
}
.background(.bar)
}
}
private struct SidebarModeSwitcher: View {
@Binding var selection: SidebarMode
var body: some View {
Picker("Right Sidebar", selection: $selection) {
Text("Comments").tag(SidebarMode.annotations)
Text("Highlights").tag(SidebarMode.highlights)
}
.pickerStyle(.segmented)
.controlSize(.small)
.padding(.vertical, 7)
.padding(.horizontal, 6)
.labelsHidden()
}
}
private struct HighlightedTextListView: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
private var totalHighlightCount: Int {
appState.highlightedTextItems.count
}
var body: some View {
VStack(spacing: 0) {
highlightHeader
Divider()
if appState.highlightedTextGroups.isEmpty {
SidebarEmptyState(
systemImage: "highlighter",
title: "No highlighted text",
message: "Highlighted passages will appear here."
)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
switch appState.highlightSortMode {
case .color:
ForEach(appState.highlightedTextGroups, id: \.id) { group in
HighlightGroupView(group: group)
}
case .page:
ForEach(appState.highlightedTextItems) { item in
HighlightRow(item: item, showsColorSwatch: true)
}
}
}
.padding(.vertical, 4)
}
}
}
}
private var highlightHeader: some View {
HStack(spacing: 8) {
Image(systemName: "highlighter")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Text("Highlights")
.font(.headline)
.lineLimit(1)
Text("\(totalHighlightCount)")
.font(.headline.monospacedDigit())
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Spacer()
Button {
appState.highlightSortMode = appState.highlightSortMode == .color ? .page : .color
} label: {
Image(systemName: appState.highlightSortMode.systemImage)
.font(.system(size: 11, weight: .semibold))
.frame(width: 22, height: 22)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.controlSize(.mini)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Sort highlights by \(appState.highlightSortMode == .color ? "page" : "color")")
.accessibilityLabel("Toggle Highlight Sort")
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
}
}
private struct HighlightGroupView: View {
let group: HighlightedTextGroup
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Capsule()
.fill(group.color)
.frame(width: 34, height: 7)
.overlay {
Capsule()
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 0.6)
}
Text(group.title)
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
Spacer()
Text("\(group.items.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
}
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 5)
.help(group.title)
ForEach(group.items) { item in
HighlightRow(item: item, showsColorSwatch: false)
}
}
}
}
private struct HighlightRow: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@State private var isRowHovered = false
let item: AnnotationSnapshot
let showsColorSwatch: Bool
private var swatchColor: Color {
Color(nsColor: AppSettings.displayColor(forHighlightColor: item.annotation.color))
}
var body: some View {
HStack(alignment: .top, spacing: 6) {
Button {
appState.selectHighlightedText(item)
} label: {
highlightSummary
}
.buttonStyle(.plain)
.help("Go to highlight on page \(item.pageLabel)")
Button(role: .destructive) {
appState.delete(item)
} label: {
Image(systemName: "trash")
.font(.caption2.weight(.semibold))
.frame(width: 20, height: 20)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Delete Highlight")
.accessibilityLabel("Delete Highlight")
.opacity(isRowHovered || item.id == appState.selectedAnnotationID ? 1 : 0.68)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
.onHover { isHovered in
isRowHovered = isHovered
appState.setCommentHover(item, isHovered: isHovered)
}
.contextMenu {
Button(role: .destructive) {
appState.delete(item)
} label: {
Label("Delete Highlight", systemImage: "trash")
}
}
}
private var highlightSummary: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.id == appState.selectedAnnotationID ? "largecircle.fill.circle" : "circle")
.font(.caption2.weight(.semibold))
.foregroundStyle(item.id == appState.selectedAnnotationID ? Color.accentColor : InterfacePalette.quietText(for: colorScheme))
.frame(width: 14, height: 18)
if showsColorSwatch {
Capsule()
.fill(swatchColor)
.frame(width: 20, height: 6)
.overlay {
Capsule()
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 0.6)
}
.padding(.top, 6)
}
VStack(alignment: .leading, spacing: 4) {
Text("p. \(item.pageLabel)")
.font(.caption2.weight(.medium))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Text(item.highlightExcerpt)
.font(.caption)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
private struct SidebarSectionHeader: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let count: Int
let systemImage: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Text(title)
.font(.headline)
.lineLimit(1)
Text("\(count)")
.font(.headline.monospacedDigit())
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
}
}
private struct SidebarEmptyState: View {
@Environment(\.colorScheme) private var colorScheme
let systemImage: String
let title: String
let message: String
var body: some View {
VStack(spacing: 9) {
Image(systemName: systemImage)
.font(.system(size: 28, weight: .regular))
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
Text(message)
.font(.caption)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.padding(18)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
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 totalCommentCount: Int {
appState.annotations.reduce(0) { partial, item in
let isVisibleReviewItem = item.isReply ? item.hasComment : item.kind != .highlight || item.hasComment
return partial + (isVisibleReviewItem ? 1 : 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()
if isFilteringComments { filterSummary }
if showsSearch || showsFilters {
Divider()
filters
}
Divider()
commentList
}
.background(.bar)
}
private var header: some View {
HStack(spacing: 7) {
Image(systemName: "text.bubble.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Comments")
Text("Comments")
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.85)
.layoutPriority(1)
Text(commentCountText)
.font(.headline.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize()
Spacer()
Button {
showsSearch.toggle()
if showsSearch {
focusCommentSearch()
} else {
isCommentSearchFocused = false
}
} label: {
Label(
"Search Comments",
systemImage: (showsSearch || hasActiveCommentSearch) ? "magnifyingglass.circle.fill" : "magnifyingglass"
)
}
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
.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)
.buttonStyle(.plain)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
.foregroundStyle(hasActiveCommentFilters ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
.help("Filter Comments")
if isFilteringComments {
Button {
clearVisibleFilters()
} label: {
Label("Clear Comment Filters", systemImage: "xmark.circle.fill")
}
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Clear Comment Filters")
.accessibilityLabel("Clear Comment Filters")
}
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
}
private var commentCountText: String {
guard isFilteringComments, totalCommentCount > 0 else { return "\(visibleCommentCount)" }
return "\(visibleCommentCount)/\(totalCommentCount)"
}
private var filters: some View {
VStack(spacing: 8) {
if showsSearch {
commentSearchField
}
if showsFilters {
Picker("Comment filter", selection: $appState.commentFilter) {
ForEach(CommentFilter.allCases, id: \.self) { 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 }, id: \.self) { 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 var filterSummary: some View {
HStack(spacing: 8) {
Label(commentCountText, systemImage: "line.3.horizontal.decrease.circle")
.labelStyle(.titleAndIcon)
.font(.caption.weight(.medium))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
Spacer(minLength: 6)
Button {
clearVisibleFilters()
} label: {
Label("Clear", systemImage: "xmark.circle")
}
.labelStyle(.titleAndIcon)
.font(.caption.weight(.medium))
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.actionText(for: colorScheme))
.help("Clear Comment Filters")
}
.padding(.horizontal, 10)
.frame(height: 30)
.background(InterfacePalette.subtleFill(for: colorScheme))
}
private var commentSearchField: some View {
ZStack(alignment: .trailing) {
TextField("Search comments", text: $appState.commentSearchText)
.textFieldStyle(.plain)
.padding(.leading, 8)
.padding(.trailing, hasActiveCommentSearch ? 28 : 8)
.frame(height: 28)
.background(InterfacePalette.fieldFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.stroke(
isCommentSearchFocused ? Color.accentColor : InterfacePalette.hairline(for: colorScheme),
lineWidth: isCommentSearchFocused ? 1.4 : 1
)
}
.focused($isCommentSearchFocused)
.onAppear {
isCommentSearchFocused = true
}
if hasActiveCommentSearch {
Button {
appState.commentSearchText = ""
focusCommentSearch()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 12, weight: .semibold))
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.padding(.trailing, 8)
.help("Clear Search")
.accessibilityLabel("Clear Search")
}
}
}
private func clearVisibleFilters() {
appState.clearCommentFilters()
if !showsSearch {
isCommentSearchFocused = false
}
}
private func focusCommentSearch() {
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 search or filters." : "Comments will appear here.")
.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
@State private var isRowHovered = false
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, 22)
.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, 8)
.padding(.vertical, 6)
.overlay(alignment: .bottom) {
Rectangle()
.fill(InterfacePalette.hairline(for: colorScheme))
.frame(height: 1)
}
}
private var parentComment: some View {
HStack(alignment: .top, spacing: 8) {
CommentMarker(symbolName: item.kind.symbolName, size: 28, font: .caption)
.frame(width: 30, alignment: .center)
.padding(.top, 2)
.help(item.kind == .reply ? "Reply" : "Comment Thread")
VStack(alignment: .leading, spacing: 5) {
Button {
appState.select(item)
} label: {
commentSummary
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityAddTraits(.isButton)
.accessibilityAction {
appState.select(item)
}
HStack(spacing: 6) {
Text(item.kind.displayName)
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
ReviewStatusChip(item: item)
Spacer()
CommentReviewRowActions(
isVisible: isRowHovered || appState.sidebarReplyParentID == item.id,
onEdit: {
appState.edit(item)
},
onReply: {
appState.beginSidebarReply(to: item, inThread: item)
},
onDelete: {
appState.delete(item)
}
)
}
}
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(rowFill(for: item))
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
.contentShape(Rectangle())
.onHover { isHovered in
isRowHovered = isHovered
appState.setCommentHover(item, isHovered: isHovered)
}
.contextMenu {
Button {
appState.edit(item)
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
appState.beginSidebarReply(to: item, inThread: item)
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Button(role: .destructive) {
appState.delete(item)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
private var commentSummary: some View {
VStack(alignment: .leading, spacing: 4) {
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 rowFill(for item: AnnotationSnapshot) -> Color {
if item.id == appState.selectedAnnotationID {
return InterfacePalette.selectedRowFill(for: colorScheme)
}
if isRowHovered {
return InterfacePalette.subtleFill(for: colorScheme)
}
return Color.clear
}
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)
.clipShape(Circle())
}
}
private struct SidebarReplyComposer: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
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) {
CommitTextView(
text: $appState.sidebarReplyDraft,
font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
onCommit: {
if !appState.sidebarReplyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
appState.commitSidebarReply()
}
}
)
.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))
.onSubmit {
if !appState.sidebarReplyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
appState.commitSidebarReply()
}
}
.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)
}
}
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
@State private var isRowHovered = false
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, 2)
.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)
}
HStack(spacing: 6) {
Text("Reply")
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
ReviewStatusChip(item: item)
Spacer()
CommentReviewRowActions(
isVisible: isRowHovered || appState.sidebarReplyParentID == threadRoot.id,
onEdit: {
appState.edit(item)
},
onReply: {
appState.beginSidebarReply(to: item, inThread: threadRoot)
},
onDelete: {
appState.delete(item)
}
)
}
}
}
.padding(.horizontal, 8)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.background(rowFill)
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
.contentShape(Rectangle())
.padding(.top, 4)
.onHover { isHovered in
isRowHovered = isHovered
appState.setCommentHover(item, isHovered: isHovered)
}
.contextMenu {
Button {
appState.edit(item)
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
appState.beginSidebarReply(to: item, inThread: threadRoot)
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Button(role: .destructive) {
appState.delete(item)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
private var rowFill: Color {
if item.id == appState.selectedAnnotationID {
return InterfacePalette.selectedRowFill(for: colorScheme)
}
if isRowHovered {
return InterfacePalette.subtleFill(for: colorScheme)
}
return Color.clear
}
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 func dateString(_ date: Date?) -> String {
guard let date else { return "No date" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}
private struct CommentReviewRowActions: View {
let isVisible: Bool
let onEdit: () -> Void
let onReply: () -> Void
let onDelete: () -> Void
var body: some View {
HStack(spacing: 5) {
Button(action: onReply) {
Label("Reply", systemImage: "arrowshape.turn.up.left")
.font(.caption2.weight(.medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.help("Reply")
.accessibilityLabel("Reply")
Menu {
Button(role: .none, action: onEdit) {
Label("Edit", systemImage: "pencil")
}
Button(role: .none, action: onReply) {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Button(role: .destructive, action: onDelete) {
Label("Delete", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 11, weight: .semibold))
.frame(width: 18, height: 18)
.contentShape(Rectangle())
}
.menuStyle(.borderlessButton)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.opacity(isVisible ? 1 : 0)
.animation(.easeInOut(duration: 0.12), value: isVisible)
.help("More Actions")
.accessibilityLabel("More Actions")
}
}
}