/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
import SwiftUI
import Defaults
struct NotchNotesView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@FocusState private var isFocused: Bool
@Default(.savedNotes) var savedNotes
@Default(.clipboardDisplayMode) var clipboardDisplayMode
@Default(.enableClipboardManager) var enableClipboardManager
@State private var selectedNoteId: UUID?
@State private var isEditingNewNote = false
// Editor State
@State private var editorTitle: String = ""
@State private var editorContent: String = ""
@State private var editorImageData: Data? = nil
@State private var editorColorIndex: Int = 0
@State private var editorNoteId: UUID?
@Default(.enableNotes) var enableNotes
var showSplitView: Bool {
return enableClipboardManager && clipboardDisplayMode == .separateTab
}
var body: some View {
HStack(spacing: 0) {
if showSplitView {
NotchClipboardList()
.frame(maxWidth: .infinity)
if enableNotes {
Divider()
.background(Color.white.opacity(0.15))
}
}
if enableNotes {
ZStack {
if isEditingNewNote || selectedNoteId != nil {
NoteEditorView(
title: $editorTitle,
content: $editorContent,
imageData: $editorImageData,
colorIndex: $editorColorIndex,
onSave: saveNote,
onCancel: cancelEdit,
isNew: isEditingNewNote
)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .trailing)))
} else {
NoteListView(
notes: savedNotes,
onSelect: selectNote,
onCreate: createNote,
onDelete: deleteNote,
onDeleteItem: deleteNoteItem,
onClearAll: clearAllNotes,
onTogglePin: togglePin,
onCreateFromClipboard: createNoteFromClipboard
)
.transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading)))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped() // Prevent overflow during transition
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isEditingNewNote)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: selectedNoteId)
.overlay {
// Hidden button for Cmd+V paste listener
Button("") {
handlePaste()
}
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
.allowsHitTesting(false)
}
}
}
.frame(maxHeight: .infinity)
.onAppear {
updateLayoutState()
}
.onDisappear {
coordinator.notesLayoutState = .list
}
.onChange(of: isEditingNewNote) { _, _ in
updateLayoutState()
}
.onChange(of: selectedNoteId) { _, _ in
updateLayoutState()
}
.onChange(of: enableClipboardManager) { _, _ in
updateLayoutState()
}
.onChange(of: clipboardDisplayMode) { _, _ in
updateLayoutState()
}
.onChange(of: enableNotes) { _, _ in
updateLayoutState()
}
}
// MARK: - Actions
private func handlePaste() {
let pasteboard = NSPasteboard.general
// Check for file URLs first (higher quality source than icons)
if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] {
let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"]
if let firstImageURL = fileURLs.first(where: { imageExtensions.contains($0.pathExtension.lowercased()) }),
let imageData = try? Data(contentsOf: firstImageURL) {
updateImageData(imageData)
return
}
}
// Then direct image data
if let tiffData = pasteboard.data(forType: .tiff) {
updateImageData(tiffData)
return
} else if let pngData = pasteboard.data(forType: .png) {
updateImageData(pngData)
return
}
// Fallback to text if not editing and clipboard has text
if !isEditingNewNote && selectedNoteId == nil {
if let text = pasteboard.string(forType: .string) {
createNoteWithContent(text)
}
}
}
private func updateImageData(_ data: Data) {
withAnimation {
if isEditingNewNote || selectedNoteId != nil {
editorImageData = data
} else {
// If not editing, create a new note with this image
editorTitle = ""
editorContent = ""
editorImageData = data
editorColorIndex = 0
editorNoteId = UUID()
isEditingNewNote = true
}
}
}
private func createNoteWithContent(_ content: String) {
editorTitle = ""
editorContent = content
editorImageData = nil
editorColorIndex = 0
editorNoteId = UUID()
isEditingNewNote = true
}
private func createNote() {
editorTitle = ""
editorContent = ""
editorImageData = nil
editorColorIndex = 0 // Default Yellow
editorNoteId = UUID()
isEditingNewNote = true
}
private func selectNote(_ note: NoteItem) {
editorTitle = note.title
editorContent = note.content
editorImageData = note.getImageData() // Load from disk
editorColorIndex = note.colorIndex
editorNoteId = note.id
selectedNoteId = note.id
isEditingNewNote = false
}
private func saveNote() {
var notes = savedNotes
let now = Date()
if let id = editorNoteId {
// Priority: Save image to disk if it exists
var fileName: String? = nil
if let data = editorImageData {
let name = "note_image_\(id.uuidString).png"
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(name)
try? data.write(to: fileURL)
fileName = name
}
if let index = notes.firstIndex(where: { $0.id == id }) {
// Update
// If there was an old image and it's being replaced or removed, delete old file
if let oldFileName = notes[index].imageFileName, oldFileName != fileName {
let oldFileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(oldFileName)
try? FileManager.default.removeItem(at: oldFileURL)
}
notes[index].title = editorTitle
notes[index].content = editorContent
notes[index].imageFileName = fileName
notes[index].colorIndex = editorColorIndex
} else {
// Create
let newNote = NoteItem(
id: id,
title: editorTitle.isEmpty ? "Untitled Note" : editorTitle,
content: editorContent,
creationDate: now,
colorIndex: editorColorIndex,
isPinned: false,
imageFileName: fileName
)
notes.insert(newNote, at: 0)
}
savedNotes = notes // Persistence fix: save back to @Default
}
closeEditor()
}
private func createNoteFromClipboard() {
let pasteboard = NSPasteboard.general
// Priority 1: Check for real image files (highest quality)
if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] {
let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"]
if let firstImageURL = fileURLs.first(where: { imageExtensions.contains($0.pathExtension.lowercased()) }),
let imageData = try? Data(contentsOf: firstImageURL) {
createNoteWithContent("")
editorImageData = imageData
return
}
}
// Priority 2: Direct image data
if let tiffData = pasteboard.data(forType: .tiff) {
createNoteWithContent("")
editorImageData = tiffData
return
} else if let pngData = pasteboard.data(forType: .png) {
createNoteWithContent("")
editorImageData = pngData
return
}
if let text = pasteboard.string(forType: .string) {
createNoteWithContent(text)
}
}
private func deleteNote(_ indexSet: IndexSet) {
var notes = savedNotes
for index in indexSet {
if index < notes.count {
if let fileName = notes[index].imageFileName {
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
}
notes.remove(atOffsets: indexSet)
savedNotes = notes
}
private func deleteNoteItem(_ note: NoteItem) {
var notes = savedNotes
if let index = notes.firstIndex(where: { $0.id == note.id }) {
// Clean up image file
if let fileName = notes[index].imageFileName {
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
notes.remove(at: index)
savedNotes = notes
}
}
private func clearAllNotes() {
// Clean up all image files
for note in savedNotes {
if let fileName = note.imageFileName {
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
savedNotes.removeAll()
}
private func cancelEdit() {
closeEditor()
}
private func togglePin(_ note: NoteItem) {
var notes = savedNotes
if let index = notes.firstIndex(where: { $0.id == note.id }) {
notes[index].isPinned.toggle()
savedNotes = notes
}
}
private func scaleDownEditor() {
closeEditor()
}
private func closeEditor() {
isEditingNewNote = false
selectedNoteId = nil
// Tiny delay to clear state after animation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
editorTitle = ""
editorContent = ""
}
updateLayoutState()
}
private func updateLayoutState() {
let newState: NotesLayoutState
if enableNotes && (isEditingNewNote || selectedNoteId != nil) {
newState = .editor
} else if showSplitView {
newState = .split
} else {
newState = .list
}
if coordinator.notesLayoutState != newState {
coordinator.notesLayoutState = newState
}
}
}
// MARK: - Subviews
struct NotchClipboardList: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var clipboardManager = ClipboardManager.shared
@State private var hoveredItemId: UUID?
@State private var justCopiedId: UUID?
@State private var suppressionToken = UUID()
@State private var isSuppressing = false
@State private var showClearHistoryAlert = false
@State private var autoCloseToken = UUID()
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Clipboard")
.font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
Spacer()
if !clipboardManager.clipboardHistory.isEmpty {
Button(action: { showClearHistoryAlert = true }) {
Image(systemName: "trash")
.font(.system(size: 14))
.foregroundStyle(.red)
.padding(6)
.background(Color.white.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 12)
if clipboardManager.clipboardHistory.isEmpty {
VStack(spacing: 8) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 32))
.foregroundStyle(.secondary.opacity(0.3))
Text("No copies yet")
.font(.headline)
.foregroundStyle(.secondary)
Text("Copy text to see it here")
.font(.caption)
.foregroundStyle(.secondary.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 30) // Add padding so it's not touching header
} else {
ZStack {
ScrollView {
LazyVStack(spacing: 4) { // Tighter spacing
ForEach(clipboardManager.clipboardHistory) { item in
NotchClipboardItemRow(
item: item,
isHovered: hoveredItemId == item.id,
justCopied: justCopiedId == item.id
)
.contentShape(Rectangle())
.onHover { isHovered in
hoveredItemId = isHovered ? item.id : nil
}
.onTapGesture {
clipboardManager.copyToClipboard(item)
withAnimation {
justCopiedId = item.id
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
if justCopiedId == item.id {
withAnimation {
justCopiedId = nil
}
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}
LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom)
.frame(height: 16)
.allowsHitTesting(false)
.frame(maxHeight: .infinity, alignment: .top)
LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom)
.frame(height: 16)
.allowsHitTesting(false)
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
}
.frame(maxHeight: .infinity)
.onHover { hovering in
updateSuppression(for: hovering)
}
.onDisappear {
updateSuppression(for: false)
}
.alert("Clear Clipboard History?", isPresented: $showClearHistoryAlert) {
Button("Clear History", role: .destructive) {
clipboardManager.clearHistory()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes every saved clipboard item from the notch tab.")
}
.onChange(of: showClearHistoryAlert) { _, isShowing in
vm.setAutoCloseSuppression(isShowing, token: autoCloseToken)
}
.onAppear {
if !clipboardManager.isMonitoring {
clipboardManager.startMonitoring()
}
}
}
private func updateSuppression(for hovering: Bool) {
guard hovering != isSuppressing else { return }
isSuppressing = hovering
vm.setScrollGestureSuppression(hovering, token: suppressionToken)
}
}
struct NotchClipboardItemRow: View {
let item: ClipboardItem
let isHovered: Bool
let justCopied: Bool
var body: some View {
HStack(spacing: 12) {
if item.type == .image, let data = item.getImageData(), let nsImage = NSImage(data: data) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(0.1), lineWidth: 0.5))
} else {
Image(systemName: justCopied ? "checkmark.circle.fill" : item.type.icon)
.font(.system(size: 14))
.foregroundColor(justCopied ? .green : .blue)
.frame(width: 20)
}
VStack(alignment: .leading, spacing: 4) {
Text(item.preview)
.font(.system(size: 13))
.foregroundStyle(.white)
.lineLimit(1)
HStack {
Text(item.type.displayName)
.font(.system(size: 10))
.foregroundStyle(.secondary)
Spacer()
Text(timeAgoString(from: item.timestamp))
.font(.system(size: 10))
.foregroundStyle(.secondary)
}
}
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor).opacity(isHovered ? 0.3 : 0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 { return "Just now" }
if interval < 3600 { return "\(Int(interval/60))m" }
return "\(Int(interval/3600))h"
}
}
struct NoteListView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
let notes: [NoteItem]
let onSelect: (NoteItem) -> Void
let onCreate: () -> Void
let onDelete: (IndexSet) -> Void
let onDeleteItem: (NoteItem) -> Void
let onClearAll: () -> Void
let onTogglePin: (NoteItem) -> Void
let onCreateFromClipboard: () -> Void
@Default(.enableNoteSearch) var enableNoteSearch
@Default(.enableNoteColorFiltering) var enableNoteColorFiltering
@Default(.enableCreateFromClipboard) var enableCreateFromClipboard
@State private var searchText = ""
@State private var selectedColorFilter: Int? = nil
@State private var isSearchExpanded = false
@State private var suppressionToken = UUID()
@State private var isSuppressing = false
@State private var showClearNotesAlert = false
@State private var autoCloseToken = UUID()
var sortedNotes: [NoteItem] {
var filtered = searchText.isEmpty ? notes : notes.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.content.localizedCaseInsensitiveContains(searchText)
}
if let colorIndex = selectedColorFilter {
filtered = filtered.filter { $0.colorIndex == colorIndex }
}
return filtered.sorted {
if $0.isPinned != $1.isPinned {
return $0.isPinned
}
return $0.creationDate > $1.creationDate
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Notes")
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
Spacer()
if enableNoteSearch || enableNoteColorFiltering {
Button(action: {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
isSearchExpanded.toggle()
}
}) {
Image(systemName: "magnifyingglass")
.font(.system(size: 14, weight: isSearchExpanded ? .bold : .medium))
.symbolVariant(isSearchExpanded ? .circle.fill : .none)
.frame(width: 16, height: 16) // Fixed frame to stabilize
.foregroundStyle(isSearchExpanded ? .blue : .white.opacity(0.6))
.padding(5)
.background(Color.white.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
if enableCreateFromClipboard {
Button(action: onCreateFromClipboard) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 13))
.frame(width: 16, height: 16)
.foregroundStyle(.white.opacity(0.6))
.padding(5)
.background(Color.white.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
.help("Create from Clipboard")
}
if !notes.isEmpty {
Button(action: { showClearNotesAlert = true }) {
Image(systemName: "trash")
.font(.system(size: 14))
.frame(width: 16, height: 16)
.foregroundStyle(.red)
.padding(5)
.background(Color.white.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
Button(action: onCreate) {
Image(systemName: "plus")
.font(.system(size: 14, weight: .bold))
.frame(width: 16, height: 16)
.foregroundStyle(.white)
.padding(5)
.background(Color.white.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
.padding(.horizontal, 16) // Reduced from 20
.padding(.top, 8)
.padding(.bottom, isSearchExpanded ? 4 : 2)
if isSearchExpanded {
VStack(spacing: 6) {
if enableNoteSearch && !notes.isEmpty {
HStack {
Image(systemName: "magnifyingglass")
.font(.system(size: 10))
.foregroundStyle(.secondary)
TextField("Search notes...", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 12))
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 10))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4) // Reduced from 6
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 16)
}
if enableNoteColorFiltering && !notes.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) { // Reduced from 8
Button(action: { selectedColorFilter = nil }) {
Text("All")
.font(.system(size: 10, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(selectedColorFilter == nil ? Color.blue.opacity(0.3) : Color.white.opacity(0.1))
.clipShape(Capsule())
}
.buttonStyle(.plain)
ForEach(0.. 3
let columns = useGrid ? [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)] : [GridItem(.flexible())]
LazyVGrid(columns: columns, spacing: 8) {
ForEach(sortedNotes) { note in
NoteRow(
note: note,
onDelete: { onDeleteItem(note) },
onTogglePin: { onTogglePin(note) },
isCompact: useGrid
)
.contentShape(Rectangle())
.onTapGesture {
onSelect(note)
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}
LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom)
.frame(height: 16)
.allowsHitTesting(false)
.frame(maxHeight: .infinity, alignment: .top)
LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom)
.frame(height: 16)
.allowsHitTesting(false)
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
}
.frame(maxHeight: .infinity)
.onHover { hovering in
updateSuppression(for: hovering)
}
.onDisappear {
updateSuppression(for: false)
}
.alert("Delete All Notes?", isPresented: $showClearNotesAlert) {
Button("Delete", role: .destructive) {
onClearAll()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This permanently removes every saved note and its attachments.")
}
.onChange(of: showClearNotesAlert) { _, isShowing in
vm.setAutoCloseSuppression(isShowing, token: autoCloseToken)
}
}
private func updateSuppression(for hovering: Bool) {
guard hovering != isSuppressing else { return }
isSuppressing = hovering
vm.setScrollGestureSuppression(hovering, token: suppressionToken)
}
}
struct NoteRow: View {
let note: NoteItem
let onDelete: () -> Void
let onTogglePin: () -> Void
let isCompact: Bool
@State private var isHovered = false
@State private var isCopied = false
@Default(.enableNotePinning) var enableNotePinning
var body: some View {
ZStack(alignment: .topTrailing) {
HStack(spacing: isCompact ? 8 : 12) {
// Color Indicator
RoundedRectangle(cornerRadius: 3)
.fill(note.color)
.frame(width: isCompact ? 3 : 4)
.padding(.vertical, isCompact ? 4 : 2)
VStack(alignment: .leading, spacing: isCompact ? 1 : 4) {
HStack(spacing: 4) {
if note.isPinned {
Image(systemName: "pin.fill")
.font(.system(size: isCompact ? 7 : 8))
.foregroundStyle(note.color)
}
Text(note.title)
.font(.system(size: isCompact ? 12 : 14, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
}
Text(note.content.isEmpty ? "No content" : note.content)
.font(.system(size: isCompact ? 10 : 12))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(isCompact ? 1 : 2)
.multilineTextAlignment(.leading)
}
.animation(.easeInOut(duration: 0.2), value: isHovered)
Spacer(minLength: 0)
}
.padding(.trailing, isCompact ? 50 : 130) // Standardized padding for alignment
.padding(isCompact ? 8 : 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(isHovered ? 0.12 : 0.08))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(note.color.opacity(0.2), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 12)) // Ensure everything stays inside the frame
.overlay(alignment: .trailing) {
HStack(spacing: 8) {
if isHovered {
HStack(spacing: isCompact ? 3 : 4) {
if enableNotePinning {
Button(action: onTogglePin) {
Image(systemName: note.isPinned ? "pin.slash.fill" : "pin.fill")
.font(.system(size: isCompact ? 9 : 11))
.foregroundStyle(.white)
.padding(isCompact ? 5 : 6)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
Button(action: {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(note.content, forType: .string)
// Also copy image data if present
if let imageData = note.getImageData(), let tiffData = NSImage(data: imageData)?.tiffRepresentation {
pasteboard.setData(tiffData, forType: .tiff)
}
withAnimation {
isCopied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
isCopied = false
}
}
}) {
Image(systemName: isCopied ? "checkmark" : "doc.on.doc")
.font(.system(size: isCompact ? 9 : 11))
.foregroundStyle(isCopied ? .green : .white)
.padding(isCompact ? 5 : 6)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: isCompact ? 9 : 11))
.foregroundStyle(.red)
.padding(isCompact ? 5 : 6)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
.padding(.horizontal, isCompact ? 6 : 8)
.padding(.vertical, isCompact ? 2 : 3)
.background(
Capsule()
.fill(Color(white: 0.12)) // Softer dark gray, not solid black
.overlay(
Capsule()
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: .black.opacity(0.3), radius: 3)
)
.padding(.vertical, 4) // Ensure it stays within note borders
.transition(.asymmetric(insertion: .opacity.combined(with: .move(edge: .trailing)), removal: .opacity))
} else if !isCompact {
Text(timeAgoString(from: note.creationDate))
.font(.system(size: 10))
.foregroundStyle(.secondary.opacity(0.6))
}
if let imageData = note.getImageData(), let nsImage = NSImage(data: imageData) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: isCompact ? 32 : 50, height: isCompact ? 32 : 50)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(0.2), lineWidth: 1))
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1)
.padding(.trailing, isCompact ? 4 : 8)
}
}
.padding(.trailing, isCompact ? 2 : 4)
}
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovered = hovering
}
}
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 { return "Just now" }
if interval < 3600 { return "\(Int(interval/60))m" }
if interval < 86400 { return "\(Int(interval/3600))h" }
return date.formatted(.dateTime.day().month())
}
}
struct NoteEditorView: View {
@Binding var title: String
@Binding var content: String
@Binding var imageData: Data?
@Binding var colorIndex: Int
let onSave: () -> Void
let cancelAction: () -> Void
let isNew: Bool
init(title: Binding, content: Binding, imageData: Binding, colorIndex: Binding, onSave: @escaping () -> Void, onCancel: @escaping () -> Void, isNew: Bool) {
self._title = title
self._content = content
self._imageData = imageData
self._colorIndex = colorIndex
self.onSave = onSave
self.cancelAction = onCancel
self.isNew = isNew
}
@FocusState private var isContentFocused: Bool
var body: some View {
VStack(spacing: 0) {
// Toolbar
HStack {
Button(action: cancelAction) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
Text("Notes")
}
.font(.system(size: 15, weight: .medium, design: .rounded))
.foregroundStyle(.white)
}
.buttonStyle(PlainButtonStyle())
Spacer()
Button(action: onSave) {
Text("Done")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(NoteItem.colors[colorIndex])
}
.buttonStyle(PlainButtonStyle())
}
.padding(16)
// Title & Color Picker Row
HStack(alignment: .center, spacing: 12) {
TextField("Title", text: $title)
.font(.system(size: 17, weight: .bold, design: .rounded))
.textFieldStyle(.plain)
Spacer()
// Color Picker
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(0..