Atoll/DynamicIsland/components/Notch/NotchNotesView.swift
SSylvain1989 c385eae0c7 Save note content when editor disappears
Extract persistNote() from saveNote() to allow saving without closing
  the editor. Call persistNote() from onDisappear (when notch retracts)
  and cancelEdit() so user changes are never silently discarded.
  Empty new notes (no title, no content, no image) are skipped.
2026-03-03 21:27:46 +01:00

1126 lines
46 KiB
Swift

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 {
if isEditingNewNote || selectedNoteId != nil {
persistNote()
}
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 persistNote() {
guard let id = editorNoteId else { return }
let isExistingNote = savedNotes.contains(where: { $0.id == id })
if !isExistingNote && editorTitle.isEmpty && editorContent.isEmpty && editorImageData == nil {
return
}
var notes = savedNotes
let now = Date()
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 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
}
private func saveNote() {
persistNote()
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() {
persistNote()
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 String(localized: "Just now") }
if interval < 3600 { return String(localized: "\(Int(interval/60))m") }
return String(localized: "\(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..<NoteItem.colors.count, id: \.self) { index in
Circle()
.fill(NoteItem.colors[index])
.frame(width: 14, height: 14)
.overlay(
Circle()
.stroke(Color.white, lineWidth: selectedColorFilter == index ? 2 : 0)
.padding(-2)
)
.onTapGesture {
withAnimation {
selectedColorFilter = (selectedColorFilter == index) ? nil : index
}
}
}
}
.padding(.horizontal, 16)
}
}
}
.transition(.move(edge: .top).combined(with: .opacity))
.padding(.bottom, 10)
}
if sortedNotes.isEmpty {
VStack {
Spacer()
HStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "note.text" : "magnifyingglass")
.font(.system(size: 16))
.foregroundStyle(.secondary.opacity(0.4))
Text(searchText.isEmpty ? "No notes yet" : "No results found")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(.secondary.opacity(0.8))
}
.padding(.top, 10)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ZStack {
ScrollView {
let useGrid = sortedNotes.count > 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 String(localized: "Just now") }
if interval < 3600 { return String(localized: "\(Int(interval/60))m") }
if interval < 86400 { return String(localized: "\(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<String>, content: Binding<String>, imageData: Binding<Data?>, colorIndex: Binding<Int>, 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..<NoteItem.colors.count, id: \.self) { index in
Circle()
.fill(NoteItem.colors[index])
.frame(width: 16, height: 16)
.overlay(
Circle()
.stroke(Color.white, lineWidth: colorIndex == index ? 2 : 0)
.padding(-2)
)
.onTapGesture {
withAnimation {
colorIndex = index
}
}
}
}
.padding(.vertical, 4)
.padding(.horizontal, 4)
}
.frame(maxWidth: 160)
}
.padding(.leading, 16)
.padding(.trailing, 4)
.padding(.bottom, 8)
Divider()
.background(Color.white.opacity(0.1))
// Content Input
ZStack(alignment: .topLeading) {
if content.isEmpty { // Placeholder
Text("Start typing...")
.font(.system(size: 13, design: .rounded)) // Reduced from 14
.foregroundStyle(.secondary.opacity(0.5))
.padding(.top, 10)
.padding(.leading, 12)
.allowsHitTesting(false)
}
TextEditor(text: $content)
.font(.system(size: 13, design: .rounded))
.foregroundStyle(.white)
.lineSpacing(4)
.scrollContentBackground(.hidden)
.padding(8)
.padding(.bottom, 20)
.padding(.trailing, imageData != nil ? 75 : 0) // Reduced from 100
.focused($isContentFocused)
.frame(maxHeight: .infinity)
.background(Color.white.opacity(0.05))
// Image Overlay in Bottom Right
if let data = imageData, let nsImage = NSImage(data: data) {
VStack {
Spacer()
HStack {
Spacer()
ZStack(alignment: .topTrailing) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 55, height: 55) // Reduced from 80
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.2), lineWidth: 1))
.shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 2)
Button(action: {
withAnimation {
imageData = nil
}
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.9), .black.opacity(0.6))
}
.buttonStyle(.plain)
.offset(x: 4, y: -4) // Move out slightly for better accessibility
}
.padding(12)
.padding(.bottom, 20) // Moved higher as requested
}
}
}
if Defaults[.enableNoteCharCount] {
VStack {
Spacer()
HStack {
Spacer()
Text("\(content.count) chars")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary.opacity(0.5))
.padding(4)
.background(Color.black.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
.padding(8)
}
}
.allowsHitTesting(false)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity) // Ensure VStack takes full space
.background(Color.black) // Ensure solid background
.onAppear {
if isNew {
isContentFocused = true
}
}
}
}