mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge branch 'pr-177' into feature/clipboard
This is a trial merge
This commit is contained in:
commit
722363cdca
11 changed files with 1229 additions and 11 deletions
|
|
@ -45,6 +45,14 @@ struct ContentView: View {
|
|||
var dynamicNotchSize: CGSize {
|
||||
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||
|
||||
if coordinator.currentView == .timer {
|
||||
return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets
|
||||
}
|
||||
|
||||
if coordinator.currentView == .notes || coordinator.currentView == .clipboard {
|
||||
return CGSize(width: baseSize.width, height: 230) // Consistent height for split views
|
||||
}
|
||||
|
||||
guard coordinator.currentView == .stats else {
|
||||
return baseSize
|
||||
}
|
||||
|
|
@ -580,6 +588,10 @@ struct ContentView: View {
|
|||
NotchStatsView()
|
||||
case .colorPicker:
|
||||
NotchColorPickerView()
|
||||
case .notes:
|
||||
NotchNotesView()
|
||||
case .clipboard:
|
||||
NotchNotesView()
|
||||
}
|
||||
}
|
||||
.transition(.asymmetric(
|
||||
|
|
|
|||
|
|
@ -277,8 +277,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight)
|
||||
}
|
||||
|
||||
// Use minimalistic or normal size based on settings, then adjust for stats layout
|
||||
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||
// Use minimalistic or normal size based on settings
|
||||
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||
|
||||
// Use a consistent height for different view types
|
||||
if coordinator.currentView == .timer {
|
||||
baseSize.height = 250 // Extra space for timer presets
|
||||
} else if coordinator.currentView == .notes || coordinator.currentView == .clipboard {
|
||||
baseSize.height = 230
|
||||
}
|
||||
|
||||
let adjustedContentSize = statsAdjustedNotchSize(
|
||||
from: baseSize,
|
||||
isStatsTabActive: coordinator.currentView == .stats,
|
||||
|
|
@ -366,9 +374,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
// Setup Privacy Indicator Manager (camera and microphone monitoring)
|
||||
PrivacyIndicatorManager.shared.startMonitoring()
|
||||
|
||||
// Observe tab changes - use debounced updates
|
||||
// Observe tab changes - use immediate updates to prevent clipping
|
||||
coordinator.$currentView.sink { [weak self] newView in
|
||||
self?.debouncedUpdateWindowSize()
|
||||
self?.updateWindowSizeIfNeeded()
|
||||
}.store(in: &cancellables)
|
||||
|
||||
// Observe stats settings changes - use debounced updates
|
||||
|
|
@ -578,6 +586,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
} else {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||
}
|
||||
case .separateTab:
|
||||
if self.vm.notchState == .closed {
|
||||
self.vm.open()
|
||||
self.coordinator.currentView = .notes
|
||||
} else {
|
||||
if self.coordinator.currentView == .notes {
|
||||
self.vm.close()
|
||||
} else {
|
||||
self.coordinator.currentView = .notes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -884,6 +884,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"%lld chars" : {
|
||||
"comment" : "A small, rounded badge displaying the number of characters in the note.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%lld colors" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -2633,6 +2637,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"All" : {
|
||||
|
||||
},
|
||||
"All-day" : {
|
||||
"localizations" : {
|
||||
|
|
@ -5921,6 +5928,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Copy text to see it here" : {
|
||||
"comment" : "A description below the \"No copies yet\" text, instructing the user to copy text to see it in the clipboard.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Core %lld" : {
|
||||
"comment" : "A label showing the ID of a CPU core. The argument is the ID of the CPU core.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
|
@ -5946,6 +5957,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Create from Clipboard" : {
|
||||
|
||||
},
|
||||
"Current custom timer:" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -6475,6 +6489,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Customize how you organize and create notes. Enabling color filtering and search helps manage large lists." : {
|
||||
"comment" : "A footer text that describes the purpose of the \"Customize how you organize and create notes\" section in the settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Customize in Settings" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -7110,6 +7128,10 @@
|
|||
},
|
||||
"Do Not Disturb" : {
|
||||
|
||||
},
|
||||
"Done" : {
|
||||
"comment" : "A button label that indicates that the user has finished editing a note.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Download" : {
|
||||
"localizations" : {
|
||||
|
|
@ -13693,6 +13715,10 @@
|
|||
},
|
||||
"No Colors Yet" : {
|
||||
|
||||
},
|
||||
"No copies yet" : {
|
||||
"comment" : "A message displayed when the clipboard is empty, indicating that there are no copied items.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No custom animation available" : {
|
||||
"localizations" : {
|
||||
|
|
@ -14123,6 +14149,9 @@
|
|||
"No Graphs Enabled" : {
|
||||
"comment" : "A label displayed when no graphs are enabled in the Stats tab.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No notes yet" : {
|
||||
|
||||
},
|
||||
"No presets configured. Add a preset to make it appear in the timer popover." : {
|
||||
"comment" : "A message displayed when there are no timer presets configured, encouraging the user to add one.",
|
||||
|
|
@ -14675,6 +14704,10 @@
|
|||
"comment" : "A section header for adjusting the expanded notch width.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Notes" : {
|
||||
"comment" : "The title of the Notes settings page.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Notify before" : {
|
||||
"comment" : "A label describing the slider that allows the user to set a reminder lead time.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
|
@ -14950,8 +14983,9 @@
|
|||
"comment" : "A label indicating that the timer is running overtime.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
|
||||
|
||||
"Panel mode shows clipboard in a floating window near the notch." : {
|
||||
"comment" : "A description of the \"Panel\" clipboard display mode.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button." : {
|
||||
|
||||
|
|
@ -15103,8 +15137,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch." : {
|
||||
|
||||
"Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
|
||||
"comment" : "A description of the \"Popover mode\" in the Clipboard Settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window." : {
|
||||
|
||||
|
|
@ -16234,6 +16269,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Search notes..." : {
|
||||
"comment" : "A placeholder text for a search bar in the note list view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Search Settings" : {
|
||||
"comment" : "A placeholder text for a search bar in the settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
|
@ -16630,6 +16669,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : {
|
||||
"comment" : "A description of the \"Separate Tab\" clipboard display mode.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Session · ↓ %@ · ↑ %@" : {
|
||||
"comment" : "A row displaying the total downloaded and uploaded bytes for the network session.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
|
@ -18271,6 +18314,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Start typing..." : {
|
||||
"comment" : "A placeholder text shown when a note's content is empty.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : {
|
||||
|
||||
},
|
||||
|
|
@ -18951,6 +18998,10 @@
|
|||
"comment" : "A section header for the timer widget settings.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Title" : {
|
||||
"comment" : "A label for the title input field in the note editor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Toggle Notch Open:" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ struct DynamicIslandHeader: View {
|
|||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
case .popover:
|
||||
showClipboardPopover.toggle()
|
||||
case .separateTab:
|
||||
coordinator.currentView = .notes
|
||||
}
|
||||
}) {
|
||||
Capsule()
|
||||
|
|
@ -207,6 +209,8 @@ struct DynamicIslandHeader: View {
|
|||
.transition(.opacity)
|
||||
}
|
||||
|
||||
|
||||
|
||||
if Defaults[.showBatteryIndicator] {
|
||||
DynamicIslandBatteryView(
|
||||
batteryWidth: 30,
|
||||
|
|
@ -238,6 +242,12 @@ struct DynamicIslandHeader: View {
|
|||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
case .popover:
|
||||
showClipboardPopover.toggle()
|
||||
case .separateTab:
|
||||
if coordinator.currentView == .notes {
|
||||
coordinator.currentView = .home
|
||||
} else {
|
||||
coordinator.currentView = .notes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
980
DynamicIsland/components/Notch/NotchNotesView.swift
Normal file
980
DynamicIsland/components/Notch/NotchNotesView.swift
Normal file
|
|
@ -0,0 +1,980 @@
|
|||
//
|
||||
// NotchNotesView.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Assistant
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct NotchNotesView: View {
|
||||
@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)
|
||||
}
|
||||
|
||||
// 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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
struct NotchClipboardList: View {
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@State private var hoveredItemId: UUID?
|
||||
@State private var justCopiedId: 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: { clipboardManager.clearHistory() }) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
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: onClearAll) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14))
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case downloads
|
||||
case shelf
|
||||
case shortcuts
|
||||
case notes
|
||||
case about
|
||||
|
||||
var id: String { rawValue }
|
||||
|
|
@ -58,6 +59,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .downloads: return "Downloads"
|
||||
case .shelf: return "Shelf"
|
||||
case .shortcuts: return "Shortcuts"
|
||||
case .notes: return "Notes"
|
||||
case .about: return "About"
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +83,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .downloads: return "square.and.arrow.down"
|
||||
case .shelf: return "books.vertical"
|
||||
case .shortcuts: return "keyboard"
|
||||
case .notes: return "note.text"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +107,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .downloads: return .gray
|
||||
case .shelf: return .brown
|
||||
case .shortcuts: return .orange
|
||||
case .notes: return .yellow
|
||||
case .about: return .secondary
|
||||
}
|
||||
}
|
||||
|
|
@ -387,6 +391,7 @@ struct SettingsView: View {
|
|||
.hudAndOSD,
|
||||
.battery,
|
||||
.stats,
|
||||
.notes,
|
||||
.clipboard,
|
||||
.screenAssistant,
|
||||
.colorPicker,
|
||||
|
|
@ -800,6 +805,10 @@ struct SettingsView: View {
|
|||
SettingsForm(tab: .shortcuts) {
|
||||
Shortcuts()
|
||||
}
|
||||
case .notes:
|
||||
SettingsForm(tab: .notes) {
|
||||
NotesSettingsView()
|
||||
}
|
||||
case .about:
|
||||
if let controller = updaterController {
|
||||
SettingsForm(tab: .about) {
|
||||
|
|
@ -4638,9 +4647,11 @@ struct ClipboardSettings: View {
|
|||
} footer: {
|
||||
switch clipboardDisplayMode {
|
||||
case .popover:
|
||||
Text("Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch.")
|
||||
Text("Popover mode shows clipboard as a dropdown attached to the clipboard button.")
|
||||
case .panel:
|
||||
Text("Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button.")
|
||||
Text("Panel mode shows clipboard in a floating window near the notch.")
|
||||
case .separateTab:
|
||||
Text("Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5196,3 +5207,34 @@ struct SettingsPermissionCallout: View {
|
|||
#Preview {
|
||||
HUD()
|
||||
}
|
||||
|
||||
struct NotesSettingsView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.notes.highlightID(for: title)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Defaults.Toggle("Enable Notes", key: .enableNotes)
|
||||
if Defaults[.enableNotes] {
|
||||
Defaults.Toggle("Enable Note Pinning", key: .enableNotePinning)
|
||||
Defaults.Toggle("Enable Note Search", key: .enableNoteSearch)
|
||||
Defaults.Toggle("Enable Color Filtering", key: .enableNoteColorFiltering)
|
||||
Defaults.Toggle("Enable Create from Clipboard", key: .enableCreateFromClipboard)
|
||||
Defaults.Toggle("Show Character Count", key: .enableNoteCharCount)
|
||||
}
|
||||
} header: {
|
||||
Text("General")
|
||||
} footer: {
|
||||
Text("Customize how you organize and create notes. Enabling color filtering and search helps manage large lists.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Notes")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ struct TabSelectionView: View {
|
|||
if Defaults[.enableStatsFeature] {
|
||||
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))
|
||||
}
|
||||
|
||||
if Defaults[.enableNotes] || (Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .separateTab) {
|
||||
let label = Defaults[.enableNotes] ? "Notes" : "Clipboard"
|
||||
let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard"
|
||||
tabsArray.append(TabModel(label: label, icon: icon, view: .notes))
|
||||
}
|
||||
|
||||
return tabsArray
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ public enum NotchViews {
|
|||
case timer
|
||||
case stats
|
||||
case colorPicker
|
||||
case notes
|
||||
case clipboard
|
||||
}
|
||||
|
||||
enum SettingsEnum {
|
||||
|
|
|
|||
|
|
@ -105,11 +105,13 @@ enum CalendarSelectionState: Codable, Defaults.Serializable {
|
|||
enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable {
|
||||
case popover = "popover" // Traditional popover attached to button
|
||||
case panel = "panel" // Floating panel near notch
|
||||
case separateTab = "separateTab" // Separate tab in Dynamic Island
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .popover: return "Popover"
|
||||
case .panel: return "Panel"
|
||||
case .separateTab: return "Separate Tab"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +119,7 @@ enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable
|
|||
switch self {
|
||||
case .popover: return "Shows clipboard as a dropdown attached to the clipboard button"
|
||||
case .panel: return "Shows clipboard in a floating panel near the notch"
|
||||
case .separateTab: return "Shows copied items in a separate tab within the Dynamic Island (merges with Notes if enabled)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -377,6 +380,86 @@ struct AIModel: Codable, Identifiable, Defaults.Serializable {
|
|||
}
|
||||
}
|
||||
|
||||
struct NoteItem: Codable, Identifiable, Defaults.Serializable, Hashable {
|
||||
var id: UUID = UUID()
|
||||
var title: String
|
||||
var content: String
|
||||
var creationDate: Date
|
||||
var colorIndex: Int // 0: Yellow, 1: Blue, 2: Red, 3: Green
|
||||
var isPinned: Bool = false
|
||||
var imageFileName: String? = nil // Store filename instead of raw data
|
||||
|
||||
// Internal property for migration
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, title, content, creationDate, colorIndex, isPinned, imageFileName, imageData
|
||||
}
|
||||
|
||||
init(id: UUID = UUID(), title: String, content: String, creationDate: Date, colorIndex: Int, isPinned: Bool = false, imageFileName: String? = nil) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.creationDate = creationDate
|
||||
self.colorIndex = colorIndex
|
||||
self.isPinned = isPinned
|
||||
self.imageFileName = imageFileName
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
content = try container.decode(String.self, forKey: .content)
|
||||
creationDate = try container.decode(Date.self, forKey: .creationDate)
|
||||
colorIndex = try container.decode(Int.self, forKey: .colorIndex)
|
||||
isPinned = try container.decode(Bool.self, forKey: .isPinned)
|
||||
|
||||
// Migration logic: if imageData exists but imageFileName doesn't, save it to disk
|
||||
if let data = try container.decodeIfPresent(Data.self, forKey: .imageData) {
|
||||
let fileName = "note_image_\(id.uuidString).png"
|
||||
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
|
||||
try? data.write(to: fileURL)
|
||||
imageFileName = fileName
|
||||
} else {
|
||||
imageFileName = try container.decodeIfPresent(String.self, forKey: .imageFileName)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(title, forKey: .title)
|
||||
try container.encode(content, forKey: .content)
|
||||
try container.encode(creationDate, forKey: .creationDate)
|
||||
try container.encode(colorIndex, forKey: .colorIndex)
|
||||
try container.encode(isPinned, forKey: .isPinned)
|
||||
try container.encode(imageFileName, forKey: .imageFileName)
|
||||
}
|
||||
|
||||
static let colors: [Color] = [.yellow, .blue, .red, .green, .purple, .orange]
|
||||
|
||||
// Directory for storing note image files
|
||||
static let noteImageDataDirectory: URL = {
|
||||
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let notesDir = documentsPath.appendingPathComponent("NoteImages")
|
||||
try? FileManager.default.createDirectory(at: notesDir, withIntermediateDirectories: true)
|
||||
return notesDir
|
||||
}()
|
||||
|
||||
var color: Color {
|
||||
if colorIndex >= 0 && colorIndex < NoteItem.colors.count {
|
||||
return NoteItem.colors[colorIndex]
|
||||
}
|
||||
return .yellow
|
||||
}
|
||||
|
||||
// Helper to get image data from file
|
||||
func getImageData() -> Data? {
|
||||
guard let fileName = imageFileName else { return nil }
|
||||
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
|
||||
return try? Data(contentsOf: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension Defaults.Keys {
|
||||
// MARK: General
|
||||
static let menubarIcon = Key<Bool>("menubarIcon", default: true)
|
||||
|
|
@ -662,6 +745,15 @@ extension Defaults.Keys {
|
|||
// MARK: Lyrics Feature
|
||||
static let enableLyrics = Key<Bool>("enableLyrics", default: true)
|
||||
|
||||
// MARK: Notes Feature
|
||||
static let enableNotes = Key<Bool>("enableNotes", default: false)
|
||||
static let enableNotePinning = Key<Bool>("enableNotePinning", default: true)
|
||||
static let enableNoteSearch = Key<Bool>("enableNoteSearch", default: false)
|
||||
static let enableNoteColorFiltering = Key<Bool>("enableNoteColorFiltering", default: false)
|
||||
static let enableCreateFromClipboard = Key<Bool>("enableCreateFromClipboard", default: true)
|
||||
static let enableNoteCharCount = Key<Bool>("enableNoteCharCount", default: true)
|
||||
static let savedNotes = Key<[NoteItem]>("savedNotes", default: [])
|
||||
|
||||
// Helper to determine the default media controller based on macOS version
|
||||
static var defaultMediaController: MediaControllerType {
|
||||
if #available(macOS 15.4, *) {
|
||||
|
|
|
|||
|
|
@ -269,6 +269,10 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
|||
private func calculateDynamicNotchSize() -> CGSize {
|
||||
// Use minimalistic size if minimalistic UI is enabled
|
||||
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||
if DynamicIslandViewCoordinator.shared.currentView == .notes {
|
||||
return CGSize(width: baseSize.width, height: 360)
|
||||
}
|
||||
|
||||
return statsAdjustedNotchSize(
|
||||
from: baseSize,
|
||||
isStatsTabActive: DynamicIslandViewCoordinator.shared.currentView == .stats,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ let batterySneakSize: CGSize = .init(width: 160, height: 1)
|
|||
|
||||
var openNotchSize: CGSize {
|
||||
let width = Defaults[.openNotchWidth]
|
||||
return .init(width: width, height: 190)
|
||||
return .init(width: width, height: 200) // Adjusted to a better middle ground
|
||||
}
|
||||
private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180)
|
||||
private let minimalisticLyricsExtraHeight: CGFloat = 40
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue