mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Fully Working Clipboard
This commit is contained in:
parent
4890e6fc28
commit
bb0b66a50e
1 changed files with 264 additions and 39 deletions
|
|
@ -9,44 +9,106 @@ import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
// Clipboard item data structure
|
// Clipboard item data structure
|
||||||
struct ClipboardItem: Identifiable, Codable {
|
struct ClipboardItem: Identifiable, Codable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let content: String
|
|
||||||
let type: ClipboardItemType
|
let type: ClipboardItemType
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
let preview: String
|
let preview: String
|
||||||
|
|
||||||
init(content: String, type: ClipboardItemType) {
|
// Store different types of data - avoid large binary data in UserDefaults
|
||||||
self.content = content
|
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.type = type
|
||||||
self.timestamp = Date()
|
self.timestamp = Date()
|
||||||
self.preview = ClipboardItem.generatePreview(content: content, type: type)
|
self.preview = ClipboardItem.generatePreview(stringData: stringData, type: type)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func generatePreview(content: String, type: ClipboardItemType) -> String {
|
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 {
|
switch type {
|
||||||
case .text:
|
case .text:
|
||||||
return String(content.prefix(50))
|
return String(stringData.prefix(50))
|
||||||
case .url:
|
case .url:
|
||||||
if let url = URL(string: content) {
|
if let url = URL(string: stringData) {
|
||||||
return url.lastPathComponent.isEmpty ? url.host ?? content : url.lastPathComponent
|
return url.lastPathComponent.isEmpty ? url.host ?? stringData : url.lastPathComponent
|
||||||
}
|
}
|
||||||
return String(content.prefix(50))
|
return String(stringData.prefix(50))
|
||||||
case .file:
|
case .file:
|
||||||
if let url = URL(string: content) {
|
if let url = URL(string: stringData) {
|
||||||
return url.lastPathComponent
|
return url.lastPathComponent
|
||||||
}
|
}
|
||||||
return "File"
|
return "File"
|
||||||
case .image:
|
case .image:
|
||||||
return "Image"
|
return "Image"
|
||||||
case .rtf:
|
case .rtf:
|
||||||
// Remove RTF formatting for preview
|
return String(stringData.prefix(50))
|
||||||
let stripped = content.replacingOccurrences(of: "\\[^\\s]*\\s?", with: "", options: .regularExpression)
|
|
||||||
return String(stripped.prefix(50))
|
|
||||||
case .unknown:
|
case .unknown:
|
||||||
return String(content.prefix(50))
|
return String(stringData.prefix(50))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -92,9 +154,25 @@ class ClipboardManager: ObservableObject {
|
||||||
private var lastChangeCount: Int = 0
|
private var lastChangeCount: Int = 0
|
||||||
private let maxHistoryItems = 3
|
private let maxHistoryItems = 3
|
||||||
|
|
||||||
|
// 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() {
|
private init() {
|
||||||
lastChangeCount = NSPasteboard.general.changeCount
|
lastChangeCount = NSPasteboard.general.changeCount
|
||||||
loadHistoryFromDefaults()
|
loadHistoryFromDefaults()
|
||||||
|
cleanupOldFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stopMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
@ -115,16 +193,58 @@ class ClipboardManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyToClipboard(_ item: ClipboardItem) {
|
func copyToClipboard(_ item: ClipboardItem) {
|
||||||
NSPasteboard.general.clearContents()
|
let pasteboard = NSPasteboard.general
|
||||||
NSPasteboard.general.setString(item.content, forType: .string)
|
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) {
|
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 }
|
clipboardHistory.removeAll { $0.id == item.id }
|
||||||
saveHistoryToDefaults()
|
saveHistoryToDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearHistory() {
|
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()
|
clipboardHistory.removeAll()
|
||||||
saveHistoryToDefaults()
|
saveHistoryToDefaults()
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +260,7 @@ class ClipboardManager: ObservableObject {
|
||||||
guard let clipboardItem = getCurrentClipboardItem() else { return }
|
guard let clipboardItem = getCurrentClipboardItem() else { return }
|
||||||
|
|
||||||
// Don't add duplicate items
|
// Don't add duplicate items
|
||||||
if !clipboardHistory.contains(where: { $0.content == clipboardItem.content && $0.type == clipboardItem.type }) {
|
if !clipboardHistory.contains(where: { isSameContent($0, clipboardItem) }) {
|
||||||
addToHistory(clipboardItem)
|
addToHistory(clipboardItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -148,34 +268,84 @@ class ClipboardManager: ObservableObject {
|
||||||
private func getCurrentClipboardItem() -> ClipboardItem? {
|
private func getCurrentClipboardItem() -> ClipboardItem? {
|
||||||
let pasteboard = NSPasteboard.general
|
let pasteboard = NSPasteboard.general
|
||||||
|
|
||||||
// Check for URL first
|
// Step 1: Check what types are available
|
||||||
if let url = pasteboard.string(forType: .URL) ?? pasteboard.string(forType: .fileURL) {
|
let hasFileURLs = pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
|
||||||
return ClipboardItem(content: url, type: .url)
|
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
|
||||||
|
|
||||||
// Check for file URLs
|
// Step 2: Smart detection based on context
|
||||||
if let fileURLs = pasteboard.propertyList(forType: .fileURL) as? [String], !fileURLs.isEmpty {
|
|
||||||
return ClipboardItem(content: fileURLs.first!, type: .file)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for images
|
// Priority 1: If there are file URLs AND the files are actual image files, treat as image files (from Finder)
|
||||||
if pasteboard.canReadItem(withDataConformingToTypes: [NSPasteboard.PasteboardType.tiff.rawValue, NSPasteboard.PasteboardType.png.rawValue]) {
|
if hasFileURLs {
|
||||||
return ClipboardItem(content: "Image data", type: .image)
|
if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] {
|
||||||
}
|
let realFileURLs = fileURLs.filter { url in
|
||||||
|
return url.isFileURL &&
|
||||||
// Check for RTF
|
!url.path.contains("/.file/id=") &&
|
||||||
if let rtfData = pasteboard.data(forType: .rtf),
|
!url.path.contains("/tmp/") &&
|
||||||
let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string {
|
!url.path.hasPrefix("/private/var/") &&
|
||||||
return ClipboardItem(content: rtfString, type: .rtf)
|
!url.path.contains("/ClipboardViewer") &&
|
||||||
}
|
FileManager.default.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
// Check for plain text
|
|
||||||
if let string = pasteboard.string(forType: .string) {
|
if !realFileURLs.isEmpty {
|
||||||
// Determine if it's a URL
|
// Check if these are image files - if so, try to load the actual image data
|
||||||
if string.hasPrefix("http://") || string.hasPrefix("https://") || string.hasPrefix("file://") {
|
let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"]
|
||||||
return ClipboardItem(content: string, type: .url)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ClipboardItem(content: string, type: .text)
|
}
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
|
|
@ -185,13 +355,35 @@ class ClipboardManager: ObservableObject {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Remove any existing items with the same content
|
// Remove any existing items with the same data
|
||||||
self.clipboardHistory.removeAll { $0.content == item.content }
|
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
|
// Add to beginning of array
|
||||||
self.clipboardHistory.insert(item, at: 0)
|
self.clipboardHistory.insert(item, at: 0)
|
||||||
|
|
||||||
// Keep only the most recent items
|
// 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 {
|
if self.clipboardHistory.count > self.maxHistoryItems {
|
||||||
self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems))
|
self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems))
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +392,39 @@ class ClipboardManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// MARK: - Persistence
|
||||||
|
|
||||||
private func saveHistoryToDefaults() {
|
private func saveHistoryToDefaults() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue