diff --git a/Mac/MainWindow/Sidebar/FolderPasteboardWriter.swift b/Mac/MainWindow/Sidebar/FolderPasteboardWriter.swift deleted file mode 100644 index b87ade020..000000000 --- a/Mac/MainWindow/Sidebar/FolderPasteboardWriter.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// FolderPasteboardWriter.swift -// NetNewsWire -// -// Created by Brent Simmons on 2/11/18. -// Copyright © 2018 Ranchero Software. All rights reserved. -// - -import AppKit -import Account -import RSCore - -extension Folder: PasteboardWriterOwner { - - public var pasteboardWriter: NSPasteboardWriting { - return FolderPasteboardWriter(folder: self) - } -} - -@objc final class FolderPasteboardWriter: NSObject, NSPasteboardWriting { - - private let folder: Folder - static let folderUTIInternal = "com.ranchero.NetNewsWire-Evergreen.internal.folder" - static let folderUTIInternalType = NSPasteboard.PasteboardType(rawValue: folderUTIInternal) - - init(folder: Folder) { - - self.folder = folder - } - - // MARK: - NSPasteboardWriting - - func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { - - return [.string, FolderPasteboardWriter.folderUTIInternalType] - } - - func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { - - let plist: Any? - - switch type { - case .string: - plist = folder.nameForDisplay - case FolderPasteboardWriter.folderUTIInternalType: - plist = internalDictionary() - default: - plist = nil - } - - return plist - } -} - -private extension FolderPasteboardWriter { - - private struct Key { - - static let name = "name" - - // Internal - static let accountID = "accountID" - static let folderID = "folderID" - } - - func internalDictionary() -> [String: Any] { - - var d = [String: Any]() - - d[Key.folderID] = folder.folderID - if let name = folder.name { - d[Key.name] = name - } - if let accountID = folder.account?.accountID { - d[Key.accountID] = accountID - } - - return d - - } -} - diff --git a/Mac/MainWindow/Sidebar/PasteboardFolder.swift b/Mac/MainWindow/Sidebar/PasteboardFolder.swift new file mode 100644 index 000000000..2a7e14eb8 --- /dev/null +++ b/Mac/MainWindow/Sidebar/PasteboardFolder.swift @@ -0,0 +1,137 @@ +// +// FolderPasteboardWriter.swift +// NetNewsWire +// +// Created by Brent Simmons on 2/11/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSCore + +typealias PasteboardFolderDictionary = [String: String] + +struct PasteboardFolder: Hashable { + + private struct Key { + static let name = "name" + // Internal + static let folderID = "folderID" + static let accountID = "accountID" + } + + + let name: String + let folderID: String? + let accountID: String? + + init(name: String, folderID: String?, accountID: String?) { + self.name = name + self.folderID = folderID + self.accountID = accountID + } + + // MARK: - Reading + + init?(dictionary: PasteboardFolderDictionary) { + guard let name = dictionary[Key.name] else { + return nil + } + + let folderID = dictionary[Key.folderID] + let accountID = dictionary[Key.accountID] + + self.init(name: name, folderID: folderID, accountID: accountID) + } + + init?(pasteboardItem: NSPasteboardItem) { + var pasteboardType: NSPasteboard.PasteboardType? + if pasteboardItem.types.contains(FolderPasteboardWriter.folderUTIInternalType) { + pasteboardType = FolderPasteboardWriter.folderUTIInternalType + } + + if let foundType = pasteboardType { + if let folderDictionary = pasteboardItem.propertyList(forType: foundType) as? PasteboardFeedDictionary { + self.init(dictionary: folderDictionary) + return + } + } + + return nil + } + + static func pasteboardFolders(with pasteboard: NSPasteboard) -> Set? { + guard let items = pasteboard.pasteboardItems else { + return nil + } + let folders = items.compactMap { PasteboardFolder(pasteboardItem: $0) } + return folders.isEmpty ? nil : Set(folders) + } + + // MARK: - Writing + + func internalDictionary() -> PasteboardFolderDictionary { + var d = PasteboardFeedDictionary() + d[PasteboardFolder.Key.name] = name + if let folderID = folderID { + d[PasteboardFolder.Key.folderID] = folderID + } + if let accountID = accountID { + d[PasteboardFolder.Key.accountID] = accountID + } + return d + } +} + +extension Folder: PasteboardWriterOwner { + + public var pasteboardWriter: NSPasteboardWriting { + return FolderPasteboardWriter(folder: self) + } +} + +@objc final class FolderPasteboardWriter: NSObject, NSPasteboardWriting { + + private let folder: Folder + static let folderUTIInternal = "com.ranchero.NetNewsWire-Evergreen.internal.folder" + static let folderUTIInternalType = NSPasteboard.PasteboardType(rawValue: folderUTIInternal) + + init(folder: Folder) { + + self.folder = folder + } + + // MARK: - NSPasteboardWriting + + func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { + + return [.string, FolderPasteboardWriter.folderUTIInternalType] + } + + func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { + + let plist: Any? + + switch type { + case .string: + plist = folder.nameForDisplay + case FolderPasteboardWriter.folderUTIInternalType: + plist = internalDictionary + default: + plist = nil + } + + return plist + } +} + +private extension FolderPasteboardWriter { + var pasteboardFolder: PasteboardFolder { + return PasteboardFolder(name: folder.name ?? "", folderID: String(folder.folderID), accountID: folder.account?.accountID) + } + + var internalDictionary: PasteboardFeedDictionary { + return pasteboardFolder.internalDictionary() + } +} diff --git a/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift b/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift index 19090392c..040a7369d 100644 --- a/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift +++ b/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift @@ -54,46 +54,66 @@ import Account } func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation { - guard let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard), !draggedFeeds.isEmpty else { + let draggedFolders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard) + let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard) + if (draggedFolders == nil && draggedFeeds == nil) || (draggedFolders != nil && draggedFeeds != nil) { return SidebarOutlineDataSource.dragOperationNone } - let parentNode = nodeForItem(item) - let contentsType = draggedFeedContentsType(draggedFeeds) - switch contentsType { - case .singleNonLocal: - let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)! - return validateSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index) - case .singleLocal: - let draggedFeed = draggedFeeds.first! - return validateSingleLocalFeedDrop(outlineView, draggedFeed, parentNode, index) - case .multipleLocal: - return validateLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) - case .multipleNonLocal, .mixed, .empty: - return SidebarOutlineDataSource.dragOperationNone + if let draggedFolders = draggedFolders { + if draggedFolders.count == 1 { + return validateLocalFolderDrop(outlineView, draggedFolders.first!, parentNode, index) + } else { + return validateLocalFoldersDrop(outlineView, draggedFolders, parentNode, index) + } } + + if let draggedFeeds = draggedFeeds { + let contentsType = draggedFeedContentsType(draggedFeeds) + + switch contentsType { + case .singleNonLocal: + let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)! + return validateSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index) + case .singleLocal: + let draggedFeed = draggedFeeds.first! + return validateSingleLocalFeedDrop(outlineView, draggedFeed, parentNode, index) + case .multipleLocal: + return validateLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) + case .multipleNonLocal, .mixed, .empty: + return SidebarOutlineDataSource.dragOperationNone + } + } + + return SidebarOutlineDataSource.dragOperationNone } func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool { - guard let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard), !draggedFeeds.isEmpty else { + let draggedFolders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard) + let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard) + if (draggedFolders == nil && draggedFeeds == nil) || (draggedFolders != nil && draggedFeeds != nil) { return false } - let parentNode = nodeForItem(item) - let contentsType = draggedFeedContentsType(draggedFeeds) - switch contentsType { - case .singleNonLocal: - let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)! - return acceptSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index) - case .singleLocal: - return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) - case .multipleLocal: - return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) - case .multipleNonLocal, .mixed, .empty: - return false + if let draggedFeeds = draggedFeeds { + let contentsType = draggedFeedContentsType(draggedFeeds) + + switch contentsType { + case .singleNonLocal: + let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)! + return acceptSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index) + case .singleLocal: + return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) + case .multipleLocal: + return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) + case .multipleNonLocal, .mixed, .empty: + return false + } } + + return false } } @@ -109,11 +129,10 @@ private extension SidebarOutlineDataSource { } func nodeRepresentsDraggableItem(_ node: Node) -> Bool { - // Don’t allow PseudoFeed or Folder to be dragged. + // Don’t allow PseudoFeed to be dragged. // This will have to be revisited later. For instance, // user-created smart feeds should be draggable, maybe. - // And we might allow dragging folders between accounts. - return node.representedObject is Feed + return node.representedObject is Folder || node.representedObject is Feed } // MARK: - Drag and Drop @@ -179,15 +198,14 @@ private extension SidebarOutlineDataSource { if violatesTagSpecificBehavior(dropTargetNode, draggedFeed) { return SidebarOutlineDataSource.dragOperationNone } - let dragOperation: NSDragOperation = localFeedsDropOperation(dropTargetNode, Set([draggedFeed])) if parentNode == dropTargetNode && index == NSOutlineViewDropOnItemIndex { - return dragOperation + return localDragOperation() } let updatedIndex = indexWhereDraggedFeedWouldAppear(dropTargetNode, draggedFeed) if parentNode !== dropTargetNode || index != updatedIndex { outlineView.setDropItem(dropTargetNode, dropChildIndex: updatedIndex) } - return dragOperation + return localDragOperation() } func validateLocalFeedsDrop(_ outlineView: NSOutlineView, _ draggedFeeds: Set, _ parentNode: Node, _ index: Int) -> NSDragOperation { @@ -204,10 +222,10 @@ private extension SidebarOutlineDataSource { if parentNode !== dropTargetNode || index != NSOutlineViewDropOnItemIndex { outlineView.setDropItem(dropTargetNode, dropChildIndex: NSOutlineViewDropOnItemIndex) } - return localFeedsDropOperation(dropTargetNode, draggedFeeds) + return localDragOperation() } - func localFeedsDropOperation(_ dropTargetNode: Node, _ draggedFeeds: Set) -> NSDragOperation { + func localDragOperation() -> NSDragOperation { if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false { return .copy } else { @@ -240,6 +258,32 @@ private extension SidebarOutlineDataSource { return accounts } + func validateLocalFolderDrop(_ outlineView: NSOutlineView, _ draggedFolder: PasteboardFolder, _ parentNode: Node, _ index: Int) -> NSDragOperation { + guard let dropAccount = parentNode.representedObject as? Account, dropAccount.accountID != draggedFolder.accountID else { + return SidebarOutlineDataSource.dragOperationNone + } + let updatedIndex = indexWhereDraggedFolderWouldAppear(parentNode, draggedFolder) + if index != updatedIndex { + outlineView.setDropItem(parentNode, dropChildIndex: updatedIndex) + } + return localDragOperation() + } + + func validateLocalFoldersDrop(_ outlineView: NSOutlineView, _ draggedFolders: Set, _ parentNode: Node, _ index: Int) -> NSDragOperation { + guard let dropAccount = parentNode.representedObject as? Account else { + return SidebarOutlineDataSource.dragOperationNone + } + for draggedFolder in draggedFolders { + if dropAccount.accountID == draggedFolder.accountID { + return SidebarOutlineDataSource.dragOperationNone + } + } + if index != NSOutlineViewDropOnItemIndex { + outlineView.setDropItem(parentNode, dropChildIndex: NSOutlineViewDropOnItemIndex) + } + return localDragOperation() + } + func copyInAccount(node: Node, to parentNode: Node) { guard let feed = node.representedObject as? Feed else { return @@ -522,6 +566,18 @@ private extension SidebarOutlineDataSource { let index = sortedNodes.firstIndex(of: draggedFeedNode)! return index } + + func indexWhereDraggedFolderWouldAppear(_ parentNode: Node, _ draggedFolder: PasteboardFolder) -> Int { + let draggedFolderWrapper = PasteboardFolderObjectWrapper(pasteboardFolder: draggedFolder) + let draggedFolderNode = Node(representedObject: draggedFolderWrapper, parent: nil) + draggedFolderNode.canHaveChildNodes = true + let nodes = parentNode.childNodes + [draggedFolderNode] + + // Revisit if the tree controller can ever be sorted in some other way. + let sortedNodes = nodes.sortedAlphabeticallyWithFoldersAtEnd() + let index = sortedNodes.firstIndex(of: draggedFolderNode)! + return index + } } final class PasteboardFeedObjectWrapper: DisplayNameProvider { @@ -535,3 +591,15 @@ final class PasteboardFeedObjectWrapper: DisplayNameProvider { self.pasteboardFeed = pasteboardFeed } } + +final class PasteboardFolderObjectWrapper: DisplayNameProvider { + + var nameForDisplay: String { + return pasteboardFolder.name + } + let pasteboardFolder: PasteboardFolder + + init(pasteboardFolder: PasteboardFolder) { + self.pasteboardFolder = pasteboardFolder + } +} diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index bb3af139d..5ffe67acf 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -233,7 +233,7 @@ 84A37CB5201ECD610087C5AF /* RenameWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A37CB4201ECD610087C5AF /* RenameWindowController.swift */; }; 84A3EE5F223B667F00557320 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; }; 84A3EE61223B667F00557320 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; }; - 84AD1EAA2031617300BC20B7 /* FolderPasteboardWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AD1EA92031617300BC20B7 /* FolderPasteboardWriter.swift */; }; + 84AD1EAA2031617300BC20B7 /* PasteboardFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AD1EA92031617300BC20B7 /* PasteboardFolder.swift */; }; 84AD1EBA2031649C00BC20B7 /* SmartFeedPasteboardWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AD1EB92031649C00BC20B7 /* SmartFeedPasteboardWriter.swift */; }; 84AD1EBC2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AD1EBB2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift */; }; 84B7178C201E66580091657D /* SidebarViewController+ContextualMenus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7178B201E66580091657D /* SidebarViewController+ContextualMenus.swift */; }; @@ -839,7 +839,7 @@ 84A1500420048DDF0046AD9A /* SendToMarsEditCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToMarsEditCommand.swift; sourceTree = ""; }; 84A37CB4201ECD610087C5AF /* RenameWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RenameWindowController.swift; sourceTree = ""; }; 84A3EE52223B667F00557320 /* DefaultFeeds.opml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = DefaultFeeds.opml; sourceTree = ""; }; - 84AD1EA92031617300BC20B7 /* FolderPasteboardWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderPasteboardWriter.swift; sourceTree = ""; }; + 84AD1EA92031617300BC20B7 /* PasteboardFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardFolder.swift; sourceTree = ""; }; 84AD1EB92031649C00BC20B7 /* SmartFeedPasteboardWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedPasteboardWriter.swift; sourceTree = ""; }; 84AD1EBB2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOutlineDataSource.swift; sourceTree = ""; }; 84B7178B201E66580091657D /* SidebarViewController+ContextualMenus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SidebarViewController+ContextualMenus.swift"; sourceTree = ""; }; @@ -1369,7 +1369,7 @@ 849A97601ED9EB96007D329B /* SidebarOutlineView.swift */, 849A97631ED9EB96007D329B /* UnreadCountView.swift */, 848D578D21543519005FFAD5 /* PasteboardFeed.swift */, - 84AD1EA92031617300BC20B7 /* FolderPasteboardWriter.swift */, + 84AD1EA92031617300BC20B7 /* PasteboardFolder.swift */, 849A97821ED9EC63007D329B /* SidebarStatusBarView.swift */, 844B5B6A1FEA224000C7C76A /* Keyboard */, 845A29251FC928C7007B49E3 /* Cell */, @@ -2468,7 +2468,7 @@ 51E3EB33229AB02C00645299 /* ErrorHandler.swift in Sources */, 8472058120142E8900AD578B /* FeedInspectorViewController.swift in Sources */, 5144EA382279FC6200D19003 /* AccountsAddLocalWindowController.swift in Sources */, - 84AD1EAA2031617300BC20B7 /* FolderPasteboardWriter.swift in Sources */, + 84AD1EAA2031617300BC20B7 /* PasteboardFolder.swift in Sources */, 5144EA51227B8E4500D19003 /* AccountsFeedbinWindowController.swift in Sources */, 84AD1EBC2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift in Sources */, 845A29241FC9255E007B49E3 /* SidebarCellAppearance.swift in Sources */,