diff --git a/PasteShow.xcodeproj/project.pbxproj b/PasteShow.xcodeproj/project.pbxproj index 2c7b55f..561a92a 100644 --- a/PasteShow.xcodeproj/project.pbxproj +++ b/PasteShow.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 3A11B3DC2A82171B0023AD0E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A11B3DB2A82171B0023AD0E /* Preview Assets.xcassets */; }; 3A11B3E42A8219AF0023AD0E /* PasteboardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A11B3E32A8219AF0023AD0E /* PasteboardManager.swift */; }; 3ACB8A592A847B37001DDE30 /* ContentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACB8A582A847B37001DDE30 /* ContentsView.swift */; }; + 3ADE2FE12A94A81A00E0C146 /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE2FE02A94A81A00E0C146 /* SplitView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -24,6 +25,7 @@ 3A11B3E32A8219AF0023AD0E /* PasteboardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardManager.swift; sourceTree = ""; }; 3ACB8A582A847B37001DDE30 /* ContentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentsView.swift; sourceTree = ""; }; 3ACB8A5A2A847DF6001DDE30 /* PasteShow.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = PasteShow.entitlements; sourceTree = ""; }; + 3ADE2FE02A94A81A00E0C146 /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -58,6 +60,7 @@ children = ( 3A11B3D42A8217160023AD0E /* App.swift */, 3A11B3D62A8217160023AD0E /* MainView.swift */, + 3ADE2FE02A94A81A00E0C146 /* SplitView.swift */, 3ACB8A582A847B37001DDE30 /* ContentsView.swift */, 3A11B3E32A8219AF0023AD0E /* PasteboardManager.swift */, 3A11B3D82A82171B0023AD0E /* Assets.xcassets */, @@ -146,6 +149,7 @@ buildActionMask = 2147483647; files = ( 3A11B3D72A8217160023AD0E /* MainView.swift in Sources */, + 3ADE2FE12A94A81A00E0C146 /* SplitView.swift in Sources */, 3A11B3E42A8219AF0023AD0E /* PasteboardManager.swift in Sources */, 3ACB8A592A847B37001DDE30 /* ContentsView.swift in Sources */, 3A11B3D52A8217160023AD0E /* App.swift in Sources */, @@ -293,7 +297,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.nuwastone.PasteShow; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -321,7 +325,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.nuwastone.PasteShow; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/PasteShow/App.swift b/PasteShow/App.swift index 5534d10..6bbf95e 100644 --- a/PasteShow/App.swift +++ b/PasteShow/App.swift @@ -9,12 +9,10 @@ import SwiftUI @main struct PasteShowApp: App { - let manager = PasteboardManager.shared - var body: some Scene { WindowGroup { MainView() - .environmentObject(manager.copiedInfo) + .environmentObject(PasteboardManager.shared.pasteInfo) } } } diff --git a/PasteShow/ContentsView.swift b/PasteShow/ContentsView.swift index 0e6cfd5..cdc0cfa 100644 --- a/PasteShow/ContentsView.swift +++ b/PasteShow/ContentsView.swift @@ -9,107 +9,81 @@ import SwiftUI import QuickLookUI import UniformTypeIdentifiers -enum ContentsType { - case UTF8Text - case UTF16Text - case RTFText - case HTMLText - case Image - case Other -} - struct ContentsView: View { + @EnvironmentObject var status: NavigationStatus let itemType: String let itemData: Data - let utType: UTType - - init(itemType: String, itemData: Data) { - self.itemType = itemType - self.itemData = itemData - self.utType = UTType(itemType) ?? .plainText - } - func getContentsType(itemType: String) -> ContentsType { - var contentsType = ContentsType.UTF8Text - - switch utType { - case .utf8PlainText, .url: - contentsType = .UTF8Text - case .utf16ExternalPlainText: - contentsType = .UTF16Text - case .rtf, .rtfd, .flatRTFD: - contentsType = .RTFText - case .html: - contentsType = .HTMLText - case .image: - contentsType = .Image - default: - contentsType = .Other + func getPlainTextView(text: String) -> some View { + GeometryReader(content: { geometry in + Text(text) + Spacer() + .frame(width: geometry.size.width) + }) + .onAppear { + status.titleString = itemType + status.subtitleString = String("\(itemData.count.formatted(.byteCount(style: .file)))") } - - return contentsType + } + + func getRichTextView(attrText: NSAttributedString) -> some View { + RichTextView(attrText: attrText) + .onAppear { + status.titleString = itemType + status.subtitleString = String("\(itemData.count.formatted(.byteCount(style: .file)))") + } } var body: some View { - let contentsType = getContentsType(itemType: itemType) - switch contentsType { - case .UTF8Text, .UTF16Text, .RTFText, .HTMLText: - CopiedTextView(textType: contentsType, textData: itemData) - .navigationSubtitle("\(itemData.count.formatted(.byteCount(style: .file)))") - case .Image: + let utType = UTType(itemType) ?? .plainText + switch utType { + case .utf8PlainText, .fileURL: + getPlainTextView(text: String(data: itemData, encoding: .utf8)!) + case .utf16ExternalPlainText: + getPlainTextView(text: String(data: itemData, encoding: .utf16)!) + case .rtf: + getRichTextView(attrText: NSAttributedString(rtf: itemData, documentAttributes: nil)!) + case .rtfd: + getRichTextView(attrText: NSAttributedString(rtfd: itemData, documentAttributes: nil)!) + case .flatRTFD: + getRichTextView(attrText: NSAttributedString(rtfd: itemData, documentAttributes: nil)!) + case .html: + getRichTextView(attrText: NSAttributedString(html: itemData, documentAttributes: nil)!) + case .png, .jpeg, .tiff, .bmp, .gif, .webP: let image = NSImage(data: itemData)! Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: image.size.width, maxHeight: image.size.height) - .navigationSubtitle("\(Int(image.size.width)) * \(Int(image.size.height)) pixel") + .onAppear { + status.titleString = itemType + status.subtitleString = String("\(Int(image.size.width)) * \(Int(image.size.height)) pixel") + } default: QuickLookView(data: itemData, type: utType) + .onAppear { + status.titleString = itemType + status.subtitleString = "" + } } } } -struct CopiedTextView: NSViewRepresentable { +struct RichTextView: NSViewRepresentable { typealias NSViewType = NSScrollView - - let textType: ContentsType - let textData: Data - - func updateTextView(textView: UnsafePointer) { - textView.pointee.textStorage?.setAttributedString(NSAttributedString()) - - switch textType { - case .UTF8Text: - textView.pointee.string = String(data: textData, encoding: .utf8) - ?? "No Preview" - case .UTF16Text: - textView.pointee.string = String(data: textData, encoding: .utf16) - ?? "No Preview" - case .RTFText: - let attrText = NSAttributedString(rtf: textData, documentAttributes: nil) - ?? NSAttributedString(rtfd: textData, documentAttributes: nil)! - textView.pointee.textStorage?.setAttributedString(attrText) - case .HTMLText: - let attrText = NSAttributedString(html: textData, documentAttributes: nil)! - textView.pointee.textStorage?.setAttributedString(attrText) - default: - textView.pointee.string = "No Preview" - } - } + let attrText: NSAttributedString func makeNSView(context: Context) -> NSViewType { let scrollView = NSTextView.scrollableTextView() - var textView = scrollView.documentView as! NSTextView - - updateTextView(textView: &textView) + let textView = scrollView.documentView as! NSTextView + textView.textStorage?.setAttributedString(attrText) return scrollView } func updateNSView(_ nsView: NSViewType, context: Context) { - var textView = nsView.documentView as! NSTextView - - updateTextView(textView: &textView) + let textView = nsView.documentView as! NSTextView + textView.textStorage?.setAttributedString(attrText) } } diff --git a/PasteShow/MainView.swift b/PasteShow/MainView.swift index 18e615c..33f656f 100644 --- a/PasteShow/MainView.swift +++ b/PasteShow/MainView.swift @@ -8,59 +8,24 @@ import SwiftUI struct MainView: View { - @EnvironmentObject private var info: CopiedInfo + @StateObject var status = NavigationStatus() var body: some View { - NavigationSplitView(sidebar: { - List(info.copiedItems, id: \.self) { item in - Section("CopiedItem") { - ForEach(item.keys.sorted(), id: \.self) { key in - NavigationLink { - ContentsView(itemType: key, itemData: item[key]!) - .navigationTitle(key) - } label: { - Text(key) - .contextMenu { - Button("Copy Data Type") { - PasteboardManager.shared.setDataWithoutReserve(data: key, forType: .string) - } - Button("Remove This Type") { - PasteboardManager.shared.removeDataWithReserve(data: item[key]!, forType: key) - } - } - } - } + NavigationSplitView { + SidebarView() + } content: { + ContentView() + .safeAreaInset(edge: .bottom, alignment: .leading) { + SourceView() + .padding() } - } - .listStyle(.sidebar) - .safeAreaInset(edge: .bottom, alignment: .leading) { - if info.sourceURL != nil { - VStack(alignment: .leading) { - Section { - Label { - let name = info.sourceURL!.lastPathComponent - Text(name.replacingOccurrences(of: ".app", with: "")) - .lineLimit(1) - } icon: { - let path = info.sourceURL!.path().removingPercentEncoding - Image(nsImage: NSWorkspace.shared.icon(forFile: path!)) - .frame(height: 18) - } - } header: { - Text("Source") - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - .padding() - } - } - }, detail: { - }) - + } detail: { + DetailView() + } .padding() + .navigationSplitViewStyle(.balanced) .frame(minWidth: CGFloat(600), minHeight: CGFloat(360)) + .environmentObject(status) } } diff --git a/PasteShow/PasteboardManager.swift b/PasteShow/PasteboardManager.swift index 53a9455..1606987 100644 --- a/PasteShow/PasteboardManager.swift +++ b/PasteShow/PasteboardManager.swift @@ -7,16 +7,39 @@ import AppKit import Foundation +import UniformTypeIdentifiers -class CopiedInfo: ObservableObject, Identifiable { - @Published var changeCount = 0 - @Published var sourceURL = URL(string: "") - @Published var copiedItems = [[String: Data]]() +enum ItemType: String { + case Text = "Text" + case HTML = "HTML" + case File = "File" + case Image = "Image" + case URL = "URL" + case Other = "Other" +} + +class PasteInfoList: ObservableObject { + struct PasteInfo { + var itemType = ItemType.Other + var sourceURL = URL(string: "") + var copiedItems = [[String: Data]]() + } + + @Published var infoList = [PasteInfo]() + + func appendInfo(source: URL, items: [[String: Data]], type: ItemType) { + var info = PasteInfo() + info.sourceURL = source + info.copiedItems = items + info.itemType = type + infoList.insert(info, at: 0) + } } class PasteboardManager { static let shared = PasteboardManager() - var copiedInfo = CopiedInfo() + var changeCount = 0 + var pasteInfo = PasteInfoList() private let pasteboard = NSPasteboard.general private var observerTimer = Timer() @@ -26,7 +49,7 @@ class PasteboardManager { private func setupObserverTimer() { observerTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true, block: { [self] _ in - guard copiedInfo.changeCount != pasteboard.changeCount else { + guard changeCount != pasteboard.changeCount else { return } @@ -35,15 +58,33 @@ class PasteboardManager { } private func onPasteboardChanged() { - copiedInfo.copiedItems.removeAll() - copiedInfo.changeCount = pasteboard.changeCount + var sourceURL = URL(string: "") + var copiedItems = [[String: Data]]() + var itemType = ItemType.Other + changeCount = pasteboard.changeCount guard pasteboard.pasteboardItems != nil else { return } if let frontApp = NSWorkspace.shared.frontmostApplication { - copiedInfo.sourceURL = frontApp.bundleURL + sourceURL = frontApp.bundleURL + } + + let utType = UTType(pasteboard.pasteboardItems!.first!.types.first!.rawValue) ?? .item + switch utType { + case .text, .plainText, .rtf, .rtfd, .utf8PlainText: + itemType = .Text + case .html: + itemType = .HTML + case .image, .png, .jpeg, .tiff, .bmp, .gif, .webP: + itemType = .Image + case .url: + itemType = .URL + case .fileURL: + itemType = .File + default: + itemType = .Other } for item in pasteboard.pasteboardItems! { @@ -54,8 +95,9 @@ class PasteboardManager { itemInfo[type.rawValue] = value } } - copiedInfo.copiedItems.append(itemInfo) + copiedItems.append(itemInfo) } + pasteInfo.appendInfo(source: sourceURL!, items: copiedItems, type: itemType) } func setDataWithoutReserve(data: String, forType type: NSPasteboard.PasteboardType) { @@ -63,19 +105,18 @@ class PasteboardManager { pasteboard.setString(data, forType: type) } - func removeDataWithReserve(data: Data, forType type: String) { + func refreshPasteItems(itemsIndex: Int) { var pasteItems = [NSPasteboardItem]() pasteboard.clearContents() - for items in copiedInfo.copiedItems { + + for items in pasteInfo.infoList[itemsIndex].copiedItems { let pasteItem = NSPasteboardItem() for pair in items { - if pair.key == type && pair.value == data { - continue - } pasteItem.setData(pair.value, forType: NSPasteboard.PasteboardType(pair.key)) } pasteItems.append(pasteItem) } + pasteInfo.infoList.remove(at: itemsIndex) pasteboard.writeObjects(pasteItems) } } diff --git a/PasteShow/SplitView.swift b/PasteShow/SplitView.swift new file mode 100644 index 0000000..6c1c619 --- /dev/null +++ b/PasteShow/SplitView.swift @@ -0,0 +1,140 @@ +// +// HistoryView.swift +// PasteShow +// +// Created by ConradSun on 2023/8/22. +// + +import SwiftUI + +class NavigationStatus: ObservableObject { + @Published var setIndex = 0 + @Published var itemIndex = 0 + @Published var titleString = "" + @Published var subtitleString = "" +} + +struct SidebarView: View { + @EnvironmentObject var info: PasteInfoList + @EnvironmentObject var status: NavigationStatus + + var body: some View { + List(0 ..< info.infoList.count, id: \.self, selection: $status.setIndex) { index in + Text("Items \(index+1) -> \(info.infoList[index].itemType.rawValue)") + .contextMenu { + if index > 0 { + Button("Set to Current") { + status.setIndex = 0 + PasteboardManager.shared.refreshPasteItems(itemsIndex: index) + } + } + } + } + } +} + +struct ContentView: View { + @EnvironmentObject var info: PasteInfoList + @EnvironmentObject var status: NavigationStatus + + func getTagBase(sectionIndex: Int) -> Int { + var count = 0 + for i in 0 ..< sectionIndex { + count = count + info.infoList[status.setIndex].copiedItems[i].count + } + + return count + } + + var body: some View { + if info.infoList.isEmpty || info.infoList[0].copiedItems.isEmpty { + Text("No Item") + } else { + let items = info.infoList[status.setIndex].copiedItems + List(0 ..< items.count, id: \.self, selection: $status.itemIndex) { index in + Section("Section \(index+1)") { + let tagBase = getTagBase(sectionIndex: index) + let types = items[index].keys.sorted() + ForEach(0 ..< types.count, id: \.self) { i in + Text(types[i]) + .tag(tagBase+i) + .lineLimit(1) + .contextMenu { + Button("Copy Data Type") { + status.itemIndex = 0 + PasteboardManager.shared.setDataWithoutReserve(data: types[i], forType: .string) + } + } + } + } + } + .navigationTitle(status.titleString) + .navigationSubtitle(status.subtitleString) + } + } +} + +struct DetailView: View { + @EnvironmentObject var info: PasteInfoList + @EnvironmentObject var status: NavigationStatus + + func setupIndex() -> (Int, Int) { + if status.setIndex >= info.infoList.count { + return (-1, -1) + } + + var sectionIndex = -1 + var itemIndex = -1 + var count = 0 + var index = 0 + + for items in info.infoList[status.setIndex].copiedItems { + if status.itemIndex < count + items.count { + sectionIndex = index + itemIndex = status.itemIndex - count + break + } + count = count + items.count + index = index + 1 + } + + return (sectionIndex, itemIndex) + } + + var body: some View { + let (sectionIndex, itemIndex) = setupIndex() + if sectionIndex == -1 || itemIndex == -1 { + Text("No Preview") + } else { + let items = info.infoList[status.setIndex].copiedItems[sectionIndex] + let type = items.keys.sorted()[itemIndex] + ContentsView(itemType: type, itemData: items[type]!) + } + } +} + +struct SourceView: View { + @EnvironmentObject var info: PasteInfoList + @EnvironmentObject var status: NavigationStatus + + var body: some View { + if !info.infoList.isEmpty { + let appUrl = info.infoList[status.setIndex].sourceURL! + VStack(alignment: .leading) { + Section { + Label { + let name = appUrl.lastPathComponent + Text(name.replacingOccurrences(of: ".app", with: "")) + .lineLimit(1) + } icon: { + let path = appUrl.path().removingPercentEncoding + Image(nsImage: NSWorkspace.shared.icon(forFile: path!)) + .frame(height: 18) + } + } header: { + Text("Source") + } + } + } + } +}