mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working Text Clipboard
This commit is contained in:
parent
7396f0242a
commit
4890e6fc28
8 changed files with 631 additions and 4 deletions
|
|
@ -94,8 +94,8 @@ struct ContentView: View {
|
||||||
isHovering = hovering
|
isHovering = hovering
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only close if mouse leaves and the notch is open
|
// Only close if mouse leaves and the notch is open and no popovers are active
|
||||||
if !hovering && vm.notchState == .open {
|
if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive {
|
||||||
vm.close()
|
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)
|
.sensoryFeedback(.alignment, trigger: haptics)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
SettingsLink(label: {
|
SettingsLink(label: {
|
||||||
|
|
@ -477,8 +484,8 @@ struct ContentView: View {
|
||||||
isHovering = false
|
isHovering = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the notch if it's open and battery popover is not active
|
// Close the notch if it's open and no popovers are active
|
||||||
if vm.notchState == .open && !vm.isBatteryPopoverActive {
|
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive {
|
||||||
vm.close()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,15 @@
|
||||||
},
|
},
|
||||||
"• New feature 1" : {
|
"• New feature 1" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"3 items" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"5 items" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"10 items" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"About" : {
|
"About" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -70,6 +79,9 @@
|
||||||
},
|
},
|
||||||
"Access Denied" : {
|
"Access Denied" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Actions" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Activate" : {
|
"Activate" : {
|
||||||
|
|
||||||
|
|
@ -244,9 +256,21 @@
|
||||||
},
|
},
|
||||||
"Clear" : {
|
"Clear" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Clear Clipboard History" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clear Data" : {
|
"Clear Data" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Clipboard" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Clipboard History" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Clipboard Manager" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Close" : {
|
"Close" : {
|
||||||
|
|
||||||
|
|
@ -266,9 +290,18 @@
|
||||||
},
|
},
|
||||||
"Controls" : {
|
"Controls" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Copy something to get started" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"CPU Usage" : {
|
"CPU Usage" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Current History" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Current Items" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Custom color" : {
|
"Custom color" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|
@ -479,6 +512,9 @@
|
||||||
},
|
},
|
||||||
"High" : {
|
"High" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"History Size" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"https://github.com/th-ch/youtube-music" : {
|
"https://github.com/th-ch/youtube-music" : {
|
||||||
|
|
||||||
|
|
@ -616,6 +652,9 @@
|
||||||
},
|
},
|
||||||
"Mirror shape" : {
|
"Mirror shape" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Monitor clipboard changes and keep a history of recent copies. The clipboard button will appear in the header next to the settings gear." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Monitoring Status" : {
|
"Monitoring Status" : {
|
||||||
|
|
||||||
|
|
@ -637,6 +676,9 @@
|
||||||
},
|
},
|
||||||
"Never hide" : {
|
"Never hide" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No clipboard history" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No custom animation available" : {
|
"No custom animation available" : {
|
||||||
|
|
||||||
|
|
@ -960,6 +1002,9 @@
|
||||||
},
|
},
|
||||||
"TheDynamicIsland" : {
|
"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" : {
|
"Time to Full Charge: %lld min" : {
|
||||||
|
|
||||||
|
|
|
||||||
190
DynamicIsland/components/Notch/ClipboardHistoryPopover.swift
Normal file
190
DynamicIsland/components/Notch/ClipboardHistoryPopover.swift
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
@EnvironmentObject var webcamManager: WebcamManager
|
||||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
|
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||||
@StateObject var tvm = TrayDrop.shared
|
@StateObject var tvm = TrayDrop.shared
|
||||||
|
@State private var showClipboardPopover = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -61,6 +64,41 @@ struct DynamicIslandHeader: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Defaults[.enableClipboardManager] {
|
||||||
|
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] {
|
if Defaults[.settingsIconInNotch] {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
SettingsWindowController.shared.showWindow()
|
SettingsWindowController.shared.showWindow()
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ struct SettingsView: View {
|
||||||
NavigationLink(value: "Stats") {
|
NavigationLink(value: "Stats") {
|
||||||
Label("Stats", systemImage: "chart.line.uptrend.xyaxis")
|
Label("Stats", systemImage: "chart.line.uptrend.xyaxis")
|
||||||
}
|
}
|
||||||
|
NavigationLink(value: "Clipboard") {
|
||||||
|
Label("Clipboard", systemImage: "doc.on.clipboard")
|
||||||
|
}
|
||||||
NavigationLink(value: "Shortcuts") {
|
NavigationLink(value: "Shortcuts") {
|
||||||
Label("Shortcuts", systemImage: "keyboard")
|
Label("Shortcuts", systemImage: "keyboard")
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +100,8 @@ struct SettingsView: View {
|
||||||
Shelf()
|
Shelf()
|
||||||
case "Stats":
|
case "Stats":
|
||||||
Stats()
|
Stats()
|
||||||
|
case "Clipboard":
|
||||||
|
ClipboardSettings()
|
||||||
case "Shortcuts":
|
case "Shortcuts":
|
||||||
Shortcuts()
|
Shortcuts()
|
||||||
case "Extensions":
|
case "Extensions":
|
||||||
|
|
@ -1300,6 +1305,126 @@ func warningBadge(_ text: String, _ description: String) -> some View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ClipboardSettings: View {
|
||||||
|
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||||
|
@Default(.enableClipboardManager) var enableClipboardManager
|
||||||
|
@Default(.clipboardHistorySize) var clipboardHistorySize
|
||||||
|
|
||||||
|
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. The clipboard button will appear in the header next to the settings gear.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableClipboardManager {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("History Size")
|
||||||
|
Spacer()
|
||||||
|
Picker("History Size", selection: $clipboardHistorySize) {
|
||||||
|
Text("3 items").tag(3)
|
||||||
|
Text("5 items").tag(5)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
#Preview {
|
||||||
HUD()
|
HUD()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
217
DynamicIsland/managers/ClipboardManager.swift
Normal file
217
DynamicIsland/managers/ClipboardManager.swift
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
//
|
||||||
|
// ClipboardManager.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 17/07/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Clipboard item data structure
|
||||||
|
struct ClipboardItem: Identifiable, Codable {
|
||||||
|
let id = UUID()
|
||||||
|
let content: String
|
||||||
|
let type: ClipboardItemType
|
||||||
|
let timestamp: Date
|
||||||
|
let preview: String
|
||||||
|
|
||||||
|
init(content: String, type: ClipboardItemType) {
|
||||||
|
self.content = content
|
||||||
|
self.type = type
|
||||||
|
self.timestamp = Date()
|
||||||
|
self.preview = ClipboardItem.generatePreview(content: content, type: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func generatePreview(content: String, type: ClipboardItemType) -> String {
|
||||||
|
switch type {
|
||||||
|
case .text:
|
||||||
|
return String(content.prefix(50))
|
||||||
|
case .url:
|
||||||
|
if let url = URL(string: content) {
|
||||||
|
return url.lastPathComponent.isEmpty ? url.host ?? content : url.lastPathComponent
|
||||||
|
}
|
||||||
|
return String(content.prefix(50))
|
||||||
|
case .file:
|
||||||
|
if let url = URL(string: content) {
|
||||||
|
return url.lastPathComponent
|
||||||
|
}
|
||||||
|
return "File"
|
||||||
|
case .image:
|
||||||
|
return "Image"
|
||||||
|
case .rtf:
|
||||||
|
// Remove RTF formatting for preview
|
||||||
|
let stripped = content.replacingOccurrences(of: "\\[^\\s]*\\s?", with: "", options: .regularExpression)
|
||||||
|
return String(stripped.prefix(50))
|
||||||
|
case .unknown:
|
||||||
|
return String(content.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
|
||||||
|
private let maxHistoryItems = 3
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
lastChangeCount = NSPasteboard.general.changeCount
|
||||||
|
loadHistoryFromDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(item.content, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteItem(_ item: ClipboardItem) {
|
||||||
|
clipboardHistory.removeAll { $0.id == item.id }
|
||||||
|
saveHistoryToDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
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: { $0.content == clipboardItem.content && $0.type == clipboardItem.type }) {
|
||||||
|
addToHistory(clipboardItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCurrentClipboardItem() -> ClipboardItem? {
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
|
||||||
|
// Check for URL first
|
||||||
|
if let url = pasteboard.string(forType: .URL) ?? pasteboard.string(forType: .fileURL) {
|
||||||
|
return ClipboardItem(content: url, type: .url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for file URLs
|
||||||
|
if let fileURLs = pasteboard.propertyList(forType: .fileURL) as? [String], !fileURLs.isEmpty {
|
||||||
|
return ClipboardItem(content: fileURLs.first!, type: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for images
|
||||||
|
if pasteboard.canReadItem(withDataConformingToTypes: [NSPasteboard.PasteboardType.tiff.rawValue, NSPasteboard.PasteboardType.png.rawValue]) {
|
||||||
|
return ClipboardItem(content: "Image data", type: .image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for RTF
|
||||||
|
if let rtfData = pasteboard.data(forType: .rtf),
|
||||||
|
let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string {
|
||||||
|
return ClipboardItem(content: rtfString, type: .rtf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for plain text
|
||||||
|
if let string = pasteboard.string(forType: .string) {
|
||||||
|
// Determine if it's a URL
|
||||||
|
if string.hasPrefix("http://") || string.hasPrefix("https://") || string.hasPrefix("file://") {
|
||||||
|
return ClipboardItem(content: string, type: .url)
|
||||||
|
}
|
||||||
|
return ClipboardItem(content: string, type: .text)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 content
|
||||||
|
self.clipboardHistory.removeAll { $0.content == item.content }
|
||||||
|
|
||||||
|
// Add to beginning of array
|
||||||
|
self.clipboardHistory.insert(item, at: 0)
|
||||||
|
|
||||||
|
// Keep only the most recent items
|
||||||
|
if self.clipboardHistory.count > self.maxHistoryItems {
|
||||||
|
self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems))
|
||||||
|
}
|
||||||
|
|
||||||
|
self.saveHistoryToDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -157,6 +157,10 @@ extension Defaults.Keys {
|
||||||
// MARK: Stats Feature
|
// MARK: Stats Feature
|
||||||
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
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)
|
||||||
|
|
||||||
// Helper to determine the default media controller based on macOS version
|
// Helper to determine the default media controller based on macOS version
|
||||||
static var defaultMediaController: MediaControllerType {
|
static var defaultMediaController: MediaControllerType {
|
||||||
if #available(macOS 15.4, *) {
|
if #available(macOS 15.4, *) {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
@Published var hideOnClosed: Bool = true
|
@Published var hideOnClosed: Bool = true
|
||||||
@Published var isHoveringCalendar: Bool = false
|
@Published var isHoveringCalendar: Bool = false
|
||||||
@Published var isBatteryPopoverActive: Bool = false
|
@Published var isBatteryPopoverActive: Bool = false
|
||||||
|
@Published var isClipboardPopoverActive: Bool = false
|
||||||
|
|
||||||
@Published var screen: String?
|
@Published var screen: String?
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue