mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #3 from Ebullioscopic/feature/clipboard
Feature/clipboard
This commit is contained in:
commit
2978a38e3c
13 changed files with 934 additions and 10 deletions
|
|
@ -94,8 +94,8 @@ struct ContentView: View {
|
|||
isHovering = hovering
|
||||
}
|
||||
|
||||
// Only close if mouse leaves and the notch is open
|
||||
if !hovering && vm.notchState == .open {
|
||||
// Only close if mouse leaves and the notch is open and no popovers are active
|
||||
if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -148,6 +148,13 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: vm.isClipboardPopoverActive) { _, newPopoverState in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.alignment, trigger: haptics)
|
||||
.contextMenu {
|
||||
SettingsLink(label: {
|
||||
|
|
@ -477,8 +484,8 @@ struct ContentView: View {
|
|||
isHovering = false
|
||||
}
|
||||
|
||||
// Close the notch if it's open and battery popover is not active
|
||||
if vm.notchState == .open && !vm.isBatteryPopoverActive {
|
||||
// Close the notch if it's open and no popovers are active
|
||||
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,6 +268,41 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
TimerManager.shared.startDemoTimer(duration: 300)
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only open clipboard if the feature is enabled
|
||||
guard Defaults[.enableClipboardManager] else { return }
|
||||
|
||||
// Find the appropriate view model based on mouse location
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
var viewModel = self.vm
|
||||
|
||||
if Defaults[.showOnAllDisplays] {
|
||||
for screen in NSScreen.screens {
|
||||
if screen.frame.contains(mouseLocation) {
|
||||
if let screenViewModel = self.viewModels[screen] {
|
||||
viewModel = screenViewModel
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the notch if it's closed
|
||||
if viewModel.notchState == .closed {
|
||||
viewModel.open()
|
||||
}
|
||||
|
||||
// Start clipboard monitoring if not already running
|
||||
if !ClipboardManager.shared.isMonitoring {
|
||||
ClipboardManager.shared.startMonitoring()
|
||||
}
|
||||
|
||||
// Post notification to trigger clipboard popover
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||
}
|
||||
|
||||
if !Defaults[.showOnAllDisplays] {
|
||||
let viewModel = self.vm
|
||||
let window = createDynamicIslandWindow(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,18 @@
|
|||
},
|
||||
"• New feature 1" : {
|
||||
|
||||
},
|
||||
"3 items" : {
|
||||
|
||||
},
|
||||
"5 items" : {
|
||||
|
||||
},
|
||||
"7 items" : {
|
||||
|
||||
},
|
||||
"10 items" : {
|
||||
|
||||
},
|
||||
"About" : {
|
||||
"localizations" : {
|
||||
|
|
@ -70,6 +82,9 @@
|
|||
},
|
||||
"Access Denied" : {
|
||||
|
||||
},
|
||||
"Actions" : {
|
||||
|
||||
},
|
||||
"Activate" : {
|
||||
|
||||
|
|
@ -244,9 +259,21 @@
|
|||
},
|
||||
"Clear" : {
|
||||
|
||||
},
|
||||
"Clear Clipboard History" : {
|
||||
|
||||
},
|
||||
"Clear Data" : {
|
||||
|
||||
},
|
||||
"Clipboard" : {
|
||||
|
||||
},
|
||||
"Clipboard History" : {
|
||||
|
||||
},
|
||||
"Clipboard Manager" : {
|
||||
|
||||
},
|
||||
"Close" : {
|
||||
|
||||
|
|
@ -266,9 +293,18 @@
|
|||
},
|
||||
"Controls" : {
|
||||
|
||||
},
|
||||
"Copy something to get started" : {
|
||||
|
||||
},
|
||||
"CPU Usage" : {
|
||||
|
||||
},
|
||||
"Current History" : {
|
||||
|
||||
},
|
||||
"Current Items" : {
|
||||
|
||||
},
|
||||
"Custom color" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -479,6 +515,9 @@
|
|||
},
|
||||
"High" : {
|
||||
|
||||
},
|
||||
"History Size" : {
|
||||
|
||||
},
|
||||
"https://github.com/th-ch/youtube-music" : {
|
||||
|
||||
|
|
@ -616,6 +655,9 @@
|
|||
},
|
||||
"Mirror shape" : {
|
||||
|
||||
},
|
||||
"Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." : {
|
||||
|
||||
},
|
||||
"Monitoring Status" : {
|
||||
|
||||
|
|
@ -637,6 +679,9 @@
|
|||
},
|
||||
"Never hide" : {
|
||||
|
||||
},
|
||||
"No clipboard history" : {
|
||||
|
||||
},
|
||||
"No custom animation available" : {
|
||||
|
||||
|
|
@ -957,9 +1002,15 @@
|
|||
},
|
||||
"System Performance" : {
|
||||
|
||||
},
|
||||
"The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history." : {
|
||||
|
||||
},
|
||||
"TheDynamicIsland" : {
|
||||
|
||||
},
|
||||
"This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header." : {
|
||||
|
||||
},
|
||||
"Time to Full Charge: %lld min" : {
|
||||
|
||||
|
|
|
|||
203
DynamicIsland/components/Notch/ClipboardHistoryPopover.swift
Normal file
203
DynamicIsland/components/Notch/ClipboardHistoryPopover.swift
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
//
|
||||
// ClipboardHistoryPopover.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 17/07/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ClipboardHistoryPopover: View {
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@Binding var isPresented: Bool
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Text("Clipboard History")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
clipboardManager.clearHistory()
|
||||
}) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(clipboardManager.clipboardHistory.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
.background(Color.gray.opacity(0.3))
|
||||
|
||||
// Content
|
||||
if clipboardManager.clipboardHistory.isEmpty {
|
||||
EmptyClipboardView()
|
||||
} else {
|
||||
ClipboardItemsList()
|
||||
}
|
||||
}
|
||||
.frame(width: 280)
|
||||
.frame(maxHeight: 200)
|
||||
.background(Color.black.opacity(0.95))
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5)
|
||||
.onHover { hovering in
|
||||
// Close popover and notch when mouse exits
|
||||
if !hovering {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
isPresented = false
|
||||
// Also close the notch if no other popovers are active
|
||||
if vm.notchState == .open && !vm.isBatteryPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func EmptyClipboardView() -> some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "clipboard")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.gray)
|
||||
Text("No clipboard history")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
Text("Copy something to get started")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.gray.opacity(0.7))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ClipboardItemsList() -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(clipboardManager.clipboardHistory) { item in
|
||||
ClipboardItemRow(item: item) {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 150)
|
||||
}
|
||||
}
|
||||
|
||||
struct ClipboardItemRow: View {
|
||||
let item: ClipboardItem
|
||||
let onCopy: () -> Void
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
// Type icon
|
||||
Image(systemName: item.type.icon)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 16)
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.preview)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
HStack {
|
||||
Text(item.type.displayName)
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.gray)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(timeAgoString(from: item.timestamp))
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Action buttons (shown on hover)
|
||||
if isHovering {
|
||||
HStack(spacing: 4) {
|
||||
Button(action: {
|
||||
clipboardManager.copyToClipboard(item)
|
||||
onCopy()
|
||||
}) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Button(action: {
|
||||
clipboardManager.deleteItem(item)
|
||||
}) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(isHovering ? Color.white.opacity(0.1) : Color.clear)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
clipboardManager.copyToClipboard(item)
|
||||
onCopy()
|
||||
}
|
||||
}
|
||||
|
||||
private func timeAgoString(from date: Date) -> String {
|
||||
let interval = Date().timeIntervalSince(date)
|
||||
|
||||
if interval < 60 {
|
||||
return "Just now"
|
||||
} else if interval < 3600 {
|
||||
let minutes = Int(interval / 60)
|
||||
return "\(minutes)m ago"
|
||||
} else if interval < 86400 {
|
||||
let hours = Int(interval / 3600)
|
||||
return "\(hours)h ago"
|
||||
} else {
|
||||
let days = Int(interval / 86400)
|
||||
return "\(days)d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ClipboardHistoryPopover(isPresented: .constant(true))
|
||||
.padding()
|
||||
.background(Color.gray)
|
||||
}
|
||||
|
|
@ -13,7 +13,10 @@ struct DynamicIslandHeader: View {
|
|||
@EnvironmentObject var webcamManager: WebcamManager
|
||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@StateObject var tvm = TrayDrop.shared
|
||||
@State private var showClipboardPopover = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
HStack {
|
||||
|
|
@ -61,6 +64,41 @@ struct DynamicIslandHeader: View {
|
|||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] {
|
||||
Button(action: {
|
||||
showClipboardPopover.toggle()
|
||||
}) {
|
||||
Capsule()
|
||||
.fill(.black)
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.imageScale(.medium)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) {
|
||||
ClipboardHistoryPopover(isPresented: $showClipboardPopover)
|
||||
.onAppear {
|
||||
vm.isClipboardPopoverActive = true
|
||||
}
|
||||
.onDisappear {
|
||||
vm.isClipboardPopoverActive = false
|
||||
}
|
||||
}
|
||||
.onChange(of: showClipboardPopover) { isOpen in
|
||||
vm.isClipboardPopoverActive = isOpen
|
||||
}
|
||||
.onAppear {
|
||||
if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring {
|
||||
clipboardManager.startMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Defaults[.settingsIconInNotch] {
|
||||
Button(action: {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
|
|
@ -100,6 +138,12 @@ struct DynamicIslandHeader: View {
|
|||
}
|
||||
.foregroundColor(.gray)
|
||||
.environmentObject(vm)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ToggleClipboardPopover"))) { _ in
|
||||
// Only toggle if clipboard is enabled
|
||||
if Defaults[.enableClipboardManager] {
|
||||
showClipboardPopover.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// DynamicIsland
|
||||
//
|
||||
// Stats tab view for system performance monitoring
|
||||
//
|
||||
// Created by Hariharan Mudaliar
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// DynamicIsland
|
||||
//
|
||||
// Created by Richard Kunkli on 07/08/2024.
|
||||
//
|
||||
// Modified by Hariharan Mudaliar
|
||||
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
|
|
@ -63,6 +63,9 @@ struct SettingsView: View {
|
|||
NavigationLink(value: "Stats") {
|
||||
Label("Stats", systemImage: "chart.line.uptrend.xyaxis")
|
||||
}
|
||||
NavigationLink(value: "Clipboard") {
|
||||
Label("Clipboard", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
NavigationLink(value: "Shortcuts") {
|
||||
Label("Shortcuts", systemImage: "keyboard")
|
||||
}
|
||||
|
|
@ -97,6 +100,8 @@ struct SettingsView: View {
|
|||
Shelf()
|
||||
case "Stats":
|
||||
Stats()
|
||||
case "Clipboard":
|
||||
ClipboardSettings()
|
||||
case "Shortcuts":
|
||||
Shortcuts()
|
||||
case "Extensions":
|
||||
|
|
@ -1300,6 +1305,132 @@ func warningBadge(_ text: String, _ description: String) -> some View {
|
|||
}
|
||||
}
|
||||
|
||||
struct ClipboardSettings: View {
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@Default(.enableClipboardManager) var enableClipboardManager
|
||||
@Default(.clipboardHistorySize) var clipboardHistorySize
|
||||
@Default(.showClipboardIcon) var showClipboardIcon
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager)
|
||||
.onChange(of: enableClipboardManager) { enabled in
|
||||
if enabled {
|
||||
clipboardManager.startMonitoring()
|
||||
} else {
|
||||
clipboardManager.stopMonitoring()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Clipboard Manager")
|
||||
} footer: {
|
||||
Text("Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.")
|
||||
}
|
||||
|
||||
if enableClipboardManager {
|
||||
Section {
|
||||
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon)
|
||||
|
||||
HStack {
|
||||
Text("History Size")
|
||||
Spacer()
|
||||
Picker("History Size", selection: $clipboardHistorySize) {
|
||||
Text("3 items").tag(3)
|
||||
Text("5 items").tag(5)
|
||||
Text("7 items").tag(7)
|
||||
Text("10 items").tag(10)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 100)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Current Items")
|
||||
Spacer()
|
||||
Text("\(clipboardManager.clipboardHistory.count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Monitoring Status")
|
||||
Spacer()
|
||||
Text(clipboardManager.isMonitoring ? "Active" : "Stopped")
|
||||
.foregroundColor(clipboardManager.isMonitoring ? .green : .secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Settings")
|
||||
} footer: {
|
||||
Text("The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Clear Clipboard History") {
|
||||
clipboardManager.clearHistory()
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
.disabled(clipboardManager.clipboardHistory.isEmpty)
|
||||
} header: {
|
||||
Text("Actions")
|
||||
} footer: {
|
||||
Text("This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header.")
|
||||
}
|
||||
|
||||
if !clipboardManager.clipboardHistory.isEmpty {
|
||||
Section {
|
||||
ForEach(clipboardManager.clipboardHistory) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: item.type.icon)
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 16)
|
||||
Text(item.type.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(timeAgoString(from: item.timestamp))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(item.preview)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
} header: {
|
||||
Text("Current History")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Clipboard")
|
||||
.onAppear {
|
||||
if enableClipboardManager && !clipboardManager.isMonitoring {
|
||||
clipboardManager.startMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeAgoString(from date: Date) -> String {
|
||||
let interval = Date().timeIntervalSince(date)
|
||||
|
||||
if interval < 60 {
|
||||
return "Just now"
|
||||
} else if interval < 3600 {
|
||||
let minutes = Int(interval / 60)
|
||||
return "\(minutes)m ago"
|
||||
} else if interval < 86400 {
|
||||
let hours = Int(interval / 3600)
|
||||
return "\(hours)h ago"
|
||||
} else {
|
||||
let days = Int(interval / 86400)
|
||||
return "\(days)d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HUD()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// DynamicIsland
|
||||
//
|
||||
// Created by Hugo Persson on 2024-08-25.
|
||||
//
|
||||
// Modified by Hariharan Mudaliar
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// DynamicIsland
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
//
|
||||
// Modified by Hariharan Mudaliar
|
||||
|
||||
import Foundation
|
||||
import Defaults
|
||||
|
|
|
|||
447
DynamicIsland/managers/ClipboardManager.swift
Normal file
447
DynamicIsland/managers/ClipboardManager.swift
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
//
|
||||
// ClipboardManager.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 17/07/25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import Defaults
|
||||
|
||||
// Clipboard item data structure
|
||||
struct ClipboardItem: Identifiable, Codable {
|
||||
let id = UUID()
|
||||
let type: ClipboardItemType
|
||||
let timestamp: Date
|
||||
let preview: String
|
||||
|
||||
// Store different types of data - avoid large binary data in UserDefaults
|
||||
let stringData: String?
|
||||
let imageFileName: String? // Store filename instead of data
|
||||
let fileURLs: [String]?
|
||||
let rtfData: Data? // RTF is typically small, so we can keep this
|
||||
|
||||
init(stringData: String, type: ClipboardItemType) {
|
||||
self.stringData = stringData
|
||||
self.imageFileName = nil
|
||||
self.fileURLs = nil
|
||||
self.rtfData = nil
|
||||
self.type = type
|
||||
self.timestamp = Date()
|
||||
self.preview = ClipboardItem.generatePreview(stringData: stringData, type: type)
|
||||
}
|
||||
|
||||
init(imageData: Data) {
|
||||
self.stringData = nil
|
||||
self.fileURLs = nil
|
||||
self.rtfData = nil
|
||||
self.type = .image
|
||||
self.timestamp = Date()
|
||||
|
||||
// Save image data to temporary file instead of storing in UserDefaults
|
||||
let fileName = "clipboard_image_\(UUID().uuidString).png"
|
||||
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
try imageData.write(to: fileURL)
|
||||
self.imageFileName = fileName
|
||||
self.preview = "Image (\(ByteCountFormatter.string(fromByteCount: Int64(imageData.count), countStyle: .file)))"
|
||||
} catch {
|
||||
print("Failed to save image data: \(error)")
|
||||
self.imageFileName = nil
|
||||
self.preview = "Image (failed to save)"
|
||||
}
|
||||
}
|
||||
|
||||
init(fileURLs: [String]) {
|
||||
self.stringData = nil
|
||||
self.imageFileName = nil
|
||||
self.fileURLs = fileURLs
|
||||
self.rtfData = nil
|
||||
self.type = .file
|
||||
self.timestamp = Date()
|
||||
|
||||
if fileURLs.count == 1, let url = URL(string: fileURLs.first!) {
|
||||
self.preview = url.lastPathComponent
|
||||
} else {
|
||||
self.preview = "\(fileURLs.count) files"
|
||||
}
|
||||
}
|
||||
|
||||
init(rtfData: Data, plainText: String) {
|
||||
// RTF data is typically small, so we can keep it in UserDefaults
|
||||
self.stringData = plainText
|
||||
self.imageFileName = nil
|
||||
self.fileURLs = nil
|
||||
self.rtfData = rtfData.count > 100000 ? nil : rtfData // Skip very large RTF files
|
||||
self.type = .rtf
|
||||
self.timestamp = Date()
|
||||
self.preview = String(plainText.prefix(50))
|
||||
}
|
||||
|
||||
// Helper to get image data from file
|
||||
func getImageData() -> Data? {
|
||||
guard let fileName = imageFileName else { return nil }
|
||||
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
|
||||
return try? Data(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
static func generatePreview(stringData: String, type: ClipboardItemType) -> String {
|
||||
switch type {
|
||||
case .text:
|
||||
return String(stringData.prefix(50))
|
||||
case .url:
|
||||
if let url = URL(string: stringData) {
|
||||
return url.lastPathComponent.isEmpty ? url.host ?? stringData : url.lastPathComponent
|
||||
}
|
||||
return String(stringData.prefix(50))
|
||||
case .file:
|
||||
if let url = URL(string: stringData) {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
return "File"
|
||||
case .image:
|
||||
return "Image"
|
||||
case .rtf:
|
||||
return String(stringData.prefix(50))
|
||||
case .unknown:
|
||||
return String(stringData.prefix(50))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClipboardItemType: String, CaseIterable, Codable {
|
||||
case text = "text"
|
||||
case url = "url"
|
||||
case file = "file"
|
||||
case image = "image"
|
||||
case rtf = "rtf"
|
||||
case unknown = "unknown"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .text: return "doc.text"
|
||||
case .url: return "link"
|
||||
case .file: return "doc"
|
||||
case .image: return "photo"
|
||||
case .rtf: return "doc.richtext"
|
||||
case .unknown: return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .text: return "Text"
|
||||
case .url: return "URL"
|
||||
case .file: return "File"
|
||||
case .image: return "Image"
|
||||
case .rtf: return "Rich Text"
|
||||
case .unknown: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ClipboardManager: ObservableObject {
|
||||
static let shared = ClipboardManager()
|
||||
|
||||
@Published var clipboardHistory: [ClipboardItem] = []
|
||||
@Published var isMonitoring: Bool = false
|
||||
|
||||
private var timer: Timer?
|
||||
private var lastChangeCount: Int = 0
|
||||
|
||||
// Use configurable history size from settings
|
||||
private var maxHistoryItems: Int {
|
||||
return Defaults[.clipboardHistorySize]
|
||||
}
|
||||
|
||||
// Directory for storing clipboard data files
|
||||
static let clipboardDataDirectory: URL = {
|
||||
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let clipboardDir = documentsPath.appendingPathComponent("ClipboardData")
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
try? FileManager.default.createDirectory(at: clipboardDir, withIntermediateDirectories: true)
|
||||
|
||||
return clipboardDir
|
||||
}()
|
||||
|
||||
private init() {
|
||||
lastChangeCount = NSPasteboard.general.changeCount
|
||||
loadHistoryFromDefaults()
|
||||
cleanupOldFiles()
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopMonitoring()
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func startMonitoring() {
|
||||
guard !isMonitoring else { return }
|
||||
|
||||
isMonitoring = true
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
self?.checkClipboard()
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring() {
|
||||
isMonitoring = false
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
func copyToClipboard(_ item: ClipboardItem) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
|
||||
switch item.type {
|
||||
case .text, .url:
|
||||
if let stringData = item.stringData {
|
||||
pasteboard.setString(stringData, forType: .string)
|
||||
}
|
||||
case .image:
|
||||
if let imageData = item.getImageData() {
|
||||
pasteboard.setData(imageData, forType: .png)
|
||||
}
|
||||
case .file:
|
||||
if let fileURLs = item.fileURLs {
|
||||
let urls = fileURLs.compactMap { URL(string: $0) }
|
||||
pasteboard.writeObjects(urls as [NSPasteboardWriting])
|
||||
}
|
||||
case .rtf:
|
||||
if let rtfData = item.rtfData {
|
||||
pasteboard.setData(rtfData, forType: .rtf)
|
||||
}
|
||||
// Also set plain text as fallback
|
||||
if let stringData = item.stringData {
|
||||
pasteboard.setString(stringData, forType: .string)
|
||||
}
|
||||
case .unknown:
|
||||
if let stringData = item.stringData {
|
||||
pasteboard.setString(stringData, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteItem(_ item: ClipboardItem) {
|
||||
// Clean up associated files
|
||||
if let fileName = item.imageFileName {
|
||||
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
clipboardHistory.removeAll { $0.id == item.id }
|
||||
saveHistoryToDefaults()
|
||||
}
|
||||
|
||||
func clearHistory() {
|
||||
// Clean up all associated files
|
||||
for item in clipboardHistory {
|
||||
if let fileName = item.imageFileName {
|
||||
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
clipboardHistory.removeAll()
|
||||
saveHistoryToDefaults()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func checkClipboard() {
|
||||
let currentChangeCount = NSPasteboard.general.changeCount
|
||||
|
||||
guard currentChangeCount != lastChangeCount else { return }
|
||||
lastChangeCount = currentChangeCount
|
||||
|
||||
guard let clipboardItem = getCurrentClipboardItem() else { return }
|
||||
|
||||
// Don't add duplicate items
|
||||
if !clipboardHistory.contains(where: { isSameContent($0, clipboardItem) }) {
|
||||
addToHistory(clipboardItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func getCurrentClipboardItem() -> ClipboardItem? {
|
||||
let pasteboard = NSPasteboard.general
|
||||
|
||||
// Step 1: Check what types are available
|
||||
let hasFileURLs = pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
|
||||
let hasImageData = pasteboard.data(forType: .png) != nil ||
|
||||
pasteboard.data(forType: .tiff) != nil ||
|
||||
pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) != nil
|
||||
let hasString = pasteboard.string(forType: .string) != nil
|
||||
|
||||
// Step 2: Smart detection based on context
|
||||
|
||||
// Priority 1: If there are file URLs AND the files are actual image files, treat as image files (from Finder)
|
||||
if hasFileURLs {
|
||||
if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] {
|
||||
let realFileURLs = fileURLs.filter { url in
|
||||
return url.isFileURL &&
|
||||
!url.path.contains("/.file/id=") &&
|
||||
!url.path.contains("/tmp/") &&
|
||||
!url.path.hasPrefix("/private/var/") &&
|
||||
!url.path.contains("/ClipboardViewer") &&
|
||||
FileManager.default.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
if !realFileURLs.isEmpty {
|
||||
// Check if these are image files - if so, try to load the actual image data
|
||||
let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"]
|
||||
let imageFiles = realFileURLs.filter { url in
|
||||
imageExtensions.contains(url.pathExtension.lowercased())
|
||||
}
|
||||
|
||||
// If we have image files from Finder, load the actual image data
|
||||
if !imageFiles.isEmpty, let firstImageURL = imageFiles.first {
|
||||
if let imageData = try? Data(contentsOf: firstImageURL) {
|
||||
return ClipboardItem(imageData: imageData)
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, treat as file(s)
|
||||
let urlStrings = realFileURLs.map { $0.absoluteString }
|
||||
return ClipboardItem(fileURLs: urlStrings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: If there's ONLY image data without file URLs (screenshots, direct image paste)
|
||||
if hasImageData && !hasFileURLs {
|
||||
if let imageData = pasteboard.data(forType: .png) {
|
||||
return ClipboardItem(imageData: imageData)
|
||||
} else if let imageData = pasteboard.data(forType: .tiff) {
|
||||
return ClipboardItem(imageData: imageData)
|
||||
} else if let imageData = pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) {
|
||||
return ClipboardItem(imageData: imageData)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Plain text (including copied text)
|
||||
if let string = pasteboard.string(forType: .string), !string.isEmpty {
|
||||
// Determine if it's a URL
|
||||
if string.hasPrefix("http://") || string.hasPrefix("https://") {
|
||||
return ClipboardItem(stringData: string, type: .url)
|
||||
}
|
||||
return ClipboardItem(stringData: string, type: .text)
|
||||
}
|
||||
|
||||
// Priority 4: RTF
|
||||
if let rtfData = pasteboard.data(forType: .rtf),
|
||||
let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string, !rtfString.isEmpty {
|
||||
return ClipboardItem(rtfData: rtfData, plainText: rtfString)
|
||||
}
|
||||
|
||||
// Priority 5: If we have image data WITH file URLs (document thumbnails),
|
||||
// this is likely a document with a preview - ignore the thumbnail and treat as unknown
|
||||
if hasImageData && hasFileURLs {
|
||||
// This is likely a document with a thumbnail preview - we don't want the thumbnail
|
||||
return nil
|
||||
}
|
||||
|
||||
// Priority 6: URL strings
|
||||
if let url = pasteboard.string(forType: .URL) {
|
||||
return ClipboardItem(stringData: url, type: .url)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func addToHistory(_ item: ClipboardItem) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Remove any existing items with the same data
|
||||
let itemsToRemove = self.clipboardHistory.filter { existingItem in
|
||||
return self.isSameContent(existingItem, item)
|
||||
}
|
||||
|
||||
// Clean up files for items being removed
|
||||
for oldItem in itemsToRemove {
|
||||
if let fileName = oldItem.imageFileName {
|
||||
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
self.clipboardHistory.removeAll { existingItem in
|
||||
return self.isSameContent(existingItem, item)
|
||||
}
|
||||
|
||||
// Add to beginning of array
|
||||
self.clipboardHistory.insert(item, at: 0)
|
||||
|
||||
// Keep only the most recent items and clean up old files
|
||||
let itemsToDelete = Array(self.clipboardHistory.dropFirst(self.maxHistoryItems))
|
||||
for oldItem in itemsToDelete {
|
||||
if let fileName = oldItem.imageFileName {
|
||||
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
if self.clipboardHistory.count > self.maxHistoryItems {
|
||||
self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems))
|
||||
}
|
||||
|
||||
self.saveHistoryToDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to compare clipboard items for duplicates
|
||||
private func isSameContent(_ item1: ClipboardItem, _ item2: ClipboardItem) -> Bool {
|
||||
if item1.type != item2.type { return false }
|
||||
|
||||
switch item1.type {
|
||||
case .text, .url, .unknown:
|
||||
return item1.stringData == item2.stringData
|
||||
case .image:
|
||||
// For images, compare the actual data if both are available
|
||||
let data1 = item1.getImageData()
|
||||
let data2 = item2.getImageData()
|
||||
return data1 == data2
|
||||
case .file:
|
||||
return item1.fileURLs == item2.fileURLs
|
||||
case .rtf:
|
||||
return item1.stringData == item2.stringData && item1.rtfData == item2.rtfData
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old image files that are no longer referenced
|
||||
private func cleanupOldFiles() {
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(at: ClipboardManager.clipboardDataDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
|
||||
let referencedFiles = Set(clipboardHistory.compactMap { $0.imageFileName })
|
||||
|
||||
for file in files {
|
||||
let fileName = file.lastPathComponent
|
||||
if !referencedFiles.contains(fileName) {
|
||||
try? FileManager.default.removeItem(at: file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func saveHistoryToDefaults() {
|
||||
if let encoded = try? JSONEncoder().encode(clipboardHistory) {
|
||||
UserDefaults.standard.set(encoded, forKey: "ClipboardHistory")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadHistoryFromDefaults() {
|
||||
if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"),
|
||||
let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) {
|
||||
clipboardHistory = history
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
// DynamicIsland
|
||||
//
|
||||
// System performance monitoring for the Dynamic Island Stats feature
|
||||
//
|
||||
// Created by Hariharan Mudaliar
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// DynamicIsland
|
||||
//
|
||||
// Created by Richard Kunkli on 2024. 10. 17..
|
||||
//
|
||||
// Modified by Hariharan Mudaliar
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
|
@ -157,6 +157,11 @@ extension Defaults.Keys {
|
|||
// MARK: Stats Feature
|
||||
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
||||
|
||||
// MARK: Clipboard Feature
|
||||
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
|
||||
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)
|
||||
static let showClipboardIcon = Key<Bool>("showClipboardIcon", default: true)
|
||||
|
||||
// Helper to determine the default media controller based on macOS version
|
||||
static var defaultMediaController: MediaControllerType {
|
||||
if #available(macOS 15.4, *) {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
|||
@Published var hideOnClosed: Bool = true
|
||||
@Published var isHoveringCalendar: Bool = false
|
||||
@Published var isBatteryPopoverActive: Bool = false
|
||||
@Published var isClipboardPopoverActive: Bool = false
|
||||
|
||||
@Published var screen: String?
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue