| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- import SwiftUI
- import AppKit
- private struct NoteCollection: Identifiable, Hashable {
- enum Kind: Hashable {
- case notes
- case archive
- case recentlyDeleted
- }
- let id: UUID
- let name: String
- let kind: Kind
- }
- struct Note: Identifiable, Equatable, Hashable, Codable {
- let id: UUID
- var title: String
- var body: String
- var updatedAt: Date
- var isArchived: Bool
- var isDeleted: Bool
- var deletedAt: Date?
- }
- struct NotesAppRootView: View {
- fileprivate enum EditorFocusField: Hashable {
- case title
- case body
- }
- @State private var searchText: String = ""
- private static let defaultCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A01")!
- private static let archiveCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A03")!
- private static let recentlyDeletedCollectionID: UUID = UUID(uuidString: "2E6A2D4E-0B3D-4E8A-A5B7-8D9E0C9E2A04")!
- @State private var selectedCollectionID: UUID? = NotesAppRootView.defaultCollectionID
- @State private var selectedNoteID: UUID? = nil
- @State private var editorFocus: EditorFocusField? = nil
- @State private var showPermanentDeleteConfirmation = false
- @StateObject private var store = NotesStore()
- private var collections: [NoteCollection] = [
- NoteCollection(id: NotesAppRootView.defaultCollectionID, name: "Notes", kind: .notes),
- NoteCollection(id: NotesAppRootView.archiveCollectionID, name: "Archive", kind: .archive),
- NoteCollection(id: NotesAppRootView.recentlyDeletedCollectionID, name: "Recently Deleted", kind: .recentlyDeleted)
- ]
- private var selectedCollection: NoteCollection? {
- collections.first { $0.id == selectedCollectionID }
- }
- private var filteredNotes: [Note] {
- guard let selectedCollection else { return [] }
- let scoped: [Note]
- switch selectedCollection.kind {
- case .notes:
- scoped = store.notes.filter { !$0.isDeleted && !$0.isArchived }
- case .archive:
- scoped = store.notes.filter { !$0.isDeleted && $0.isArchived }
- case .recentlyDeleted:
- scoped = store.notes.filter { $0.isDeleted }
- }
- let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- if trimmed.isEmpty { return scoped }
- return scoped.filter {
- $0.title.lowercased().contains(trimmed) || $0.body.lowercased().contains(trimmed)
- }
- }
- private func addNote() {
- let now = Date()
- let newNote = Note(
- id: UUID(),
- title: "New Note",
- body: "",
- updatedAt: now,
- isArchived: false,
- isDeleted: false,
- deletedAt: nil
- )
- selectedCollectionID = NotesAppRootView.defaultCollectionID
- store.notes.insert(newNote, at: 0)
- selectedNoteID = newNote.id
- editorFocus = .title
- }
- private var canDeleteSelectedNote: Bool {
- guard let selectedNoteID else { return false }
- return store.notes.contains { $0.id == selectedNoteID }
- }
- private var canMoveToRecentlyDeleted: Bool {
- guard let selectedCollection else { return false }
- guard let selectedNoteID else { return false }
- guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
- if note.isDeleted { return false }
- return selectedCollection.kind == .notes || selectedCollection.kind == .archive
- }
- private func moveSelectedNoteToRecentlyDeleted() {
- guard let selectedNoteID else { return }
- guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
- store.notes[index].isDeleted = true
- store.notes[index].deletedAt = Date()
- // Stay on the current sidebar section (e.g. Notes); clear selection since the note left this list.
- self.selectedNoteID = nil
- editorFocus = nil
- }
- private var canPermanentlyDeleteSelectedNote: Bool {
- guard let selectedCollection else { return false }
- guard let selectedNoteID else { return false }
- guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
- return selectedCollection.kind == .recentlyDeleted && note.isDeleted
- }
- private func permanentlyDeleteSelectedNote() {
- guard let selectedNoteID else { return }
- store.notes.removeAll { $0.id == selectedNoteID }
- self.selectedNoteID = nil
- editorFocus = nil
- }
- private var canRestoreSelectedNote: Bool {
- guard let selectedCollection else { return false }
- guard let selectedNoteID else { return false }
- guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
- return selectedCollection.kind == .recentlyDeleted && note.isDeleted
- }
- private func restoreSelectedNote() {
- guard let selectedNoteID else { return }
- guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
- store.notes[index].isDeleted = false
- store.notes[index].deletedAt = nil
- if store.notes[index].isArchived {
- selectedCollectionID = NotesAppRootView.archiveCollectionID
- } else {
- selectedCollectionID = NotesAppRootView.defaultCollectionID
- }
- self.selectedNoteID = store.notes[index].id
- }
- private var canArchiveSelectedNote: Bool {
- guard let selectedNoteID else { return false }
- guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
- guard let selectedCollection else { return false }
- if note.isDeleted { return false }
- return selectedCollection.kind == .notes && !note.isArchived
- }
- private func archiveSelectedNote() {
- guard let selectedNoteID else { return }
- guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
- store.notes[index].isArchived = true
- // Stay on Notes; clear selection since archived notes no longer appear in this list.
- self.selectedNoteID = nil
- editorFocus = nil
- }
- private var canUnarchiveSelectedNote: Bool {
- guard let selectedNoteID else { return false }
- guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
- guard let selectedCollection else { return false }
- if note.isDeleted { return false }
- return selectedCollection.kind == .archive && note.isArchived
- }
- private func unarchiveSelectedNote() {
- guard let selectedNoteID else { return }
- guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
- store.notes[index].isArchived = false
- selectedCollectionID = NotesAppRootView.defaultCollectionID
- self.selectedNoteID = store.notes[index].id
- editorFocus = nil
- }
- var body: some View {
- NavigationSplitView {
- SidebarView(
- collections: collections,
- notes: store.notes,
- selectedCollectionID: $selectedCollectionID
- )
- } content: {
- VStack(spacing: 0) {
- HStack(spacing: 10) {
- SearchBar(text: $searchText)
- Button(action: addNote) {
- Image(systemName: "square.and.pencil")
- .font(.system(size: 14, weight: .semibold))
- }
- .buttonStyle(.borderless)
- .keyboardShortcut("n", modifiers: [.command])
- .help("New Note")
- Button(action: moveSelectedNoteToRecentlyDeleted) {
- Image(systemName: "trash")
- .font(.system(size: 14, weight: .semibold))
- }
- .buttonStyle(.borderless)
- .keyboardShortcut(.delete, modifiers: [.command])
- .help("Delete Note")
- .disabled(!canMoveToRecentlyDeleted)
- Button(action: restoreSelectedNote) {
- Image(systemName: "arrow.uturn.left")
- .font(.system(size: 14, weight: .semibold))
- }
- .buttonStyle(.borderless)
- .keyboardShortcut("z", modifiers: [.command, .shift])
- .help("Restore Note")
- .disabled(!canRestoreSelectedNote)
- Button(action: { showPermanentDeleteConfirmation = true }) {
- Image(systemName: "trash.slash")
- .font(.system(size: 14, weight: .semibold))
- }
- .buttonStyle(.borderless)
- .keyboardShortcut(.delete, modifiers: [.command, .option])
- .help("Delete Permanently")
- .disabled(!canPermanentlyDeleteSelectedNote)
- Button(action: archiveSelectedNote) {
- Image(systemName: "archivebox")
- .font(.system(size: 14, weight: .semibold))
- }
- .buttonStyle(.borderless)
- .keyboardShortcut("a", modifiers: [.command, .shift])
- .help("Archive Note")
- .disabled(!canArchiveSelectedNote)
- Button(action: unarchiveSelectedNote) {
- Image(systemName: "tray.and.arrow.up")
- .font(.system(size: 14, weight: .semibold))
- }
- .buttonStyle(.borderless)
- .keyboardShortcut("u", modifiers: [.command, .shift])
- .help("Unarchive Note")
- .disabled(!canUnarchiveSelectedNote)
- .padding(.trailing, 12)
- }
- Group {
- if filteredNotes.isEmpty {
- EmptyListView(text: "No Notes")
- } else {
- NotesListView(
- notes: filteredNotes,
- selectedNoteID: $selectedNoteID
- )
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- .background(Color(nsColor: .textBackgroundColor))
- } detail: {
- NoteEditorView(
- notes: $store.notes,
- selectedNoteID: selectedNoteID,
- focusField: $editorFocus
- )
- .background(Color(nsColor: .textBackgroundColor))
- }
- .preferredColorScheme(.dark)
- .onAppear {
- store.purgeExpiredRecentlyDeletedIfNeeded()
- }
- .alert("Delete permanently?", isPresented: $showPermanentDeleteConfirmation) {
- Button("Cancel", role: .cancel) {}
- Button("Delete Permanently", role: .destructive) {
- permanentlyDeleteSelectedNote()
- }
- } message: {
- Text("This note will be permanently removed from this device. Notes in Recently Deleted are also removed automatically after 30 days. You cannot undo this action.")
- }
- }
- }
- private struct SidebarView: View {
- let collections: [NoteCollection]
- let notes: [Note]
- @Binding var selectedCollectionID: UUID?
- private func notesCount(for collection: NoteCollection) -> Int {
- switch collection.kind {
- case .notes:
- return notes.filter { !$0.isDeleted && !$0.isArchived }.count
- case .archive:
- return notes.filter { !$0.isDeleted && $0.isArchived }.count
- case .recentlyDeleted:
- return notes.filter { $0.isDeleted }.count
- }
- }
- var body: some View {
- List(selection: $selectedCollectionID) {
- Section(header: Text("Keep Notebook")) {
- ForEach(collections) { collection in
- HStack(spacing: 8) {
- Text(collection.name)
- .foregroundStyle(.primary)
- Spacer(minLength: 8)
- Text("\(notesCount(for: collection))")
- .foregroundStyle(.secondary)
- .font(.system(size: 12, weight: .semibold))
- }
- .tag(collection.id)
- }
- }
- }
- .listStyle(.sidebar)
- .frame(minWidth: 210)
- }
- }
- private struct SearchBar: View {
- @Binding var text: String
- var body: some View {
- HStack(spacing: 8) {
- Image(systemName: "magnifyingglass")
- .foregroundStyle(.secondary)
- TextField("Search", text: $text)
- .textFieldStyle(.plain)
- .foregroundStyle(.primary)
- }
- .padding(10)
- .background(Color(nsColor: .windowBackgroundColor))
- .overlay(
- RoundedRectangle(cornerRadius: 10)
- .stroke(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 1)
- )
- .padding(12)
- }
- }
- private struct EmptyListView: View {
- let text: String
- var body: some View {
- VStack {
- Spacer()
- Text(text)
- .foregroundStyle(.secondary)
- .font(.system(size: 16, weight: .semibold))
- Spacer()
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- }
- private struct NotesListView: View {
- let notes: [Note]
- @Binding var selectedNoteID: UUID?
- var body: some View {
- List(selection: $selectedNoteID) {
- ForEach(notes) { note in
- HStack(spacing: 8) {
- Text(note.title)
- .lineLimit(1)
- Spacer(minLength: 8)
- }
- .tag(note.id)
- }
- }
- .listStyle(.inset)
- }
- }
- private struct NoteEditorView: View {
- @Binding var notes: [Note]
- let selectedNoteID: UUID?
- @Binding var focusField: NotesAppRootView.EditorFocusField?
- @FocusState private var focusedField: NotesAppRootView.EditorFocusField?
- var body: some View {
- VStack(spacing: 10) {
- if let selectedNoteID,
- let index = notes.firstIndex(where: { $0.id == selectedNoteID }) {
- TextField("Title", text: $notes[index].title)
- .textFieldStyle(.plain)
- .font(.system(size: 22, weight: .semibold))
- .padding(.horizontal, 14)
- .focused($focusedField, equals: .title)
- Divider()
- .padding(.horizontal, 14)
- TextEditor(text: $notes[index].body)
- .font(.system(size: 14))
- .foregroundStyle(.primary)
- .padding(14)
- .background(Color(nsColor: .textBackgroundColor))
- .focused($focusedField, equals: .body)
- } else {
- // Keep the editor empty to match the screenshot's "nothing selected" state.
- Spacer()
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .onChange(of: focusField) { _, newValue in
- guard let newValue else { return }
- focusedField = newValue
- focusField = nil
- }
- }
- }
|