Skip to content

Commit 6f5891b

Browse files
authored
Improve drag and drop items (#1145)
* Improve RItem based drag item data representation Allow drag and drop of multiple valid items Fix TagFilterViewController drop logic * Add text data representation for RItem based drag item * Improve DragDropController
1 parent b6ff562 commit 6f5891b

File tree

7 files changed

+133
-107
lines changed

7 files changed

+133
-107
lines changed

Zotero/Controllers/Citation/CitationController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ private struct StyleData {
2424
}
2525
}
2626

27+
@preconcurrency
2728
class CitationController: NSObject {
2829
struct Session: Hashable {
2930
let id = UUID()

Zotero/Controllers/DragDropController.swift

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,48 +7,74 @@
77
//
88

99
import UIKit
10+
import UniformTypeIdentifiers
1011

11-
final class DragDropController {
12-
func dragItem(from item: RItem) -> UIDragItem {
13-
let provider = NSItemProvider(object: item.key as NSString)
14-
let dragItem = UIDragItem(itemProvider: provider)
15-
dragItem.localObject = item
16-
return dragItem
17-
}
12+
@preconcurrency
13+
import RxSwift
1814

19-
func item(from dragItem: UIDragItem) -> RItem? {
20-
return dragItem.localObject as? RItem
21-
}
15+
struct DragSessionItemsLocalContext {
16+
let libraryIdentifier: LibraryIdentifier
17+
let keys: Set<String>
2218

23-
func dragItem(from tag: RTag) -> UIDragItem {
24-
let provider = NSItemProvider(object: tag.name as NSString)
25-
let dragItem = UIDragItem(itemProvider: provider)
26-
dragItem.localObject = tag
27-
return dragItem
28-
}
29-
30-
func tag(from dragItem: UIDragItem) -> RTag? {
31-
return dragItem.localObject as? RTag
19+
func createNewContext(with item: RItem) -> Self? {
20+
guard libraryIdentifier == item.libraryIdentifier, !keys.contains(item.key) else { return nil }
21+
return Self(libraryIdentifier: libraryIdentifier, keys: keys.union([item.key]))
3222
}
23+
}
3324

34-
func keys(from dragItems: [UIDragItem], completed: @escaping (Set<String>) -> Void) {
35-
var keys: Set<String> = []
36-
37-
let group = DispatchGroup()
38-
39-
for dragItem in dragItems {
40-
group.enter()
25+
final class DragDropController {
26+
func dragItem(from item: RItem, citationController: CitationController?, disposeBag: DisposeBag) -> UIDragItem {
27+
let itemProvider = NSItemProvider()
28+
if let citationController {
29+
registerDataRepresentation(for: itemProvider, contentType: .html, item: item, citationController: citationController, format: .html, disposeBag: disposeBag)
30+
registerDataRepresentation(for: itemProvider, contentType: .plainText, item: item, citationController: citationController, format: .text, disposeBag: disposeBag)
31+
}
32+
let dragItem = UIDragItem(itemProvider: itemProvider)
33+
dragItem.localObject = item
34+
return dragItem
4135

42-
dragItem.itemProvider.loadObject(ofClass: NSString.self) { nsString, _ in
43-
if let key = nsString as? String {
44-
keys.insert(key)
36+
func registerDataRepresentation(
37+
for itemProvider: NSItemProvider,
38+
contentType: UTType,
39+
item: RItem,
40+
citationController: CitationController,
41+
format: CitationController.Format,
42+
disposeBag: DisposeBag
43+
) {
44+
let key = item.key
45+
let libraryId = item.libraryIdentifier
46+
itemProvider.registerDataRepresentation(for: contentType, visibility: .all) { completion in
47+
let progress = Progress(totalUnitCount: 2)
48+
DispatchQueue.main.async {
49+
var session: CitationController.Session?
50+
citationController.startSession(
51+
for: Set(arrayLiteral: key),
52+
libraryId: libraryId,
53+
styleId: Defaults.shared.quickCopyStyleId,
54+
localeId: Defaults.shared.quickCopyLocaleId
55+
)
56+
.flatMap({ startedSession -> Single<String> in
57+
session = startedSession
58+
progress.completedUnitCount = 1
59+
return citationController.bibliography(for: startedSession, format: format)
60+
})
61+
.subscribe { bibliography in
62+
progress.completedUnitCount = 2
63+
completion(bibliography.data(using: .utf8), nil)
64+
if let session {
65+
citationController.endSession(session)
66+
}
67+
} onFailure: { error in
68+
progress.completedUnitCount = 2
69+
completion(nil, error)
70+
if let session {
71+
citationController.endSession(session)
72+
}
73+
}
74+
.disposed(by: disposeBag)
4575
}
46-
group.leave()
76+
return progress
4777
}
4878
}
49-
50-
group.notify(queue: .main) {
51-
completed(keys)
52-
}
5379
}
5480
}

Zotero/Scenes/Detail/Items/Views/ItemsTableViewHandler.swift

Lines changed: 33 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ final class ItemsTableViewHandler: NSObject {
5454
private unowned let delegate: ItemsTableViewHandlerDelegate
5555
private unowned let dataSource: ItemsTableViewDataSource
5656
private unowned let dragDropController: DragDropController
57+
private unowned let citationController: CitationController?
5758
private let disposeBag: DisposeBag
5859

5960
private var reloadAnimationsDisabled: Bool
@@ -62,12 +63,14 @@ final class ItemsTableViewHandler: NSObject {
6263
tableView: UITableView,
6364
delegate: ItemsTableViewHandlerDelegate,
6465
dataSource: ItemsTableViewDataSource,
65-
dragDropController: DragDropController
66+
dragDropController: DragDropController,
67+
citationController: CitationController?
6668
) {
6769
self.tableView = tableView
6870
self.delegate = delegate
6971
self.dataSource = dataSource
7072
self.dragDropController = dragDropController
73+
self.citationController = citationController
7174
reloadAnimationsDisabled = false
7275
disposeBag = DisposeBag()
7376

@@ -308,56 +311,47 @@ extension ItemsTableViewHandler: UITableViewDelegate {
308311
extension ItemsTableViewHandler: UITableViewDragDelegate {
309312
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
310313
guard let item = dataSource.object(at: indexPath.row) as? RItem else { return [] }
311-
return [self.dragDropController.dragItem(from: item)]
314+
session.localContext = DragSessionItemsLocalContext(libraryIdentifier: item.libraryIdentifier, keys: Set([item.key]))
315+
return [dragDropController.dragItem(from: item, citationController: citationController, disposeBag: disposeBag)]
316+
}
317+
318+
func tableView(_ tableView: UITableView, itemsForAddingTo session: any UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
319+
guard let item = dataSource.object(at: indexPath.row) as? RItem,
320+
let localContext = session.localContext as? DragSessionItemsLocalContext,
321+
let newLocalContext = localContext.createNewContext(with: item)
322+
else { return [] }
323+
session.localContext = newLocalContext
324+
return [dragDropController.dragItem(from: item, citationController: citationController, disposeBag: disposeBag)]
312325
}
313326
}
314327

315328
extension ItemsTableViewHandler: UITableViewDropDelegate {
316329
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
317-
guard let object = coordinator.destinationIndexPath.flatMap({ dataSource.object(at: $0.row) }) else { return }
318-
330+
guard let indexPath = coordinator.destinationIndexPath, let object = dataSource.object(at: indexPath.row) else { return }
319331
switch coordinator.proposal.operation {
320332
case .copy:
321333
let key = object.key
322-
let localObject = coordinator.items.first?.dragItem.localObject
323-
self.dragDropController.keys(from: coordinator.items.map({ $0.dragItem })) { [weak self] keys in
324-
guard let self else { return }
325-
if localObject is RItem {
326-
delegate.process(dragAndDropAction: .moveItems(keys: keys, toKey: key))
327-
} else if localObject is RTag {
328-
delegate.process(dragAndDropAction: .tagItem(key: key, libraryId: object.libraryIdentifier, tags: keys))
329-
}
330-
}
331-
default: break
332-
}
333-
}
334+
guard let localContext = coordinator.session.localDragSession?.localContext as? DragSessionItemsLocalContext, !localContext.keys.isEmpty else { break }
335+
delegate.process(dragAndDropAction: .moveItems(keys: localContext.keys, toKey: key))
334336

335-
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
336-
guard
337-
delegate.library.metadataEditable, // allow only when library is editable
338-
session.localDragSession != nil, // allow only local drag session
339-
let destinationIndexPath = destinationIndexPath,
340-
destinationIndexPath.row < dataSource.count,
341-
session.items.first?.localObject is RItem
342-
else {
343-
return UITableViewDropProposal(operation: .forbidden)
337+
default:
338+
break
344339
}
345-
return self.itemDropSessionDidUpdate(session: session, withDestinationIndexPath: destinationIndexPath)
346340
}
347341

348-
private func itemDropSessionDidUpdate(session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath) -> UITableViewDropProposal {
349-
guard let object = dataSource.object(at: destinationIndexPath.row) else {
350-
return UITableViewDropProposal(operation: .forbidden)
351-
}
352-
let dragItemsLibraryId = session.items.compactMap({ $0.localObject as? RItem }).compactMap({ $0.libraryId }).first
353-
354-
if dragItemsLibraryId != object.libraryIdentifier || // allow dropping only to the same library
355-
object.isNote || object.isAttachment || // allow dropping only to non-standalone items
356-
session.items.compactMap({ self.dragDropController.item(from: $0) }) // allow drops of only standalone items
357-
.contains(where: { $0.rawType != ItemTypes.attachment && $0.rawType != ItemTypes.note }) {
358-
return UITableViewDropProposal(operation: .forbidden)
359-
}
360-
342+
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
343+
let library = delegate.library
344+
guard library.metadataEditable,
345+
let localContext = session.localDragSession?.localContext as? DragSessionItemsLocalContext,
346+
localContext.libraryIdentifier == library.identifier,
347+
!localContext.keys.isEmpty,
348+
let destinationIndexPath,
349+
destinationIndexPath.row < dataSource.count,
350+
let object = dataSource.object(at: destinationIndexPath.row),
351+
!object.isNote,
352+
!object.isAttachment,
353+
!session.items.compactMap({ $0.localObject as? RItem }).contains(where: { $0.rawType != ItemTypes.attachment && $0.rawType != ItemTypes.note })
354+
else { return UITableViewDropProposal(operation: .forbidden) }
361355
return UITableViewDropProposal(operation: .copy, intent: .insertIntoDestinationIndexPath)
362356
}
363357
}

Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ final class ItemsViewController: BaseItemsViewController {
5050
recognizerController: controllers.userControllers?.recognizerController,
5151
schemaController: controllers.schemaController
5252
)
53-
handler = ItemsTableViewHandler(tableView: tableView, delegate: self, dataSource: dataSource, dragDropController: controllers.dragDropController)
53+
handler = ItemsTableViewHandler(
54+
tableView: tableView,
55+
delegate: self,
56+
dataSource: dataSource,
57+
dragDropController: controllers.dragDropController,
58+
citationController: controllers.userControllers?.citationController
59+
)
5460
toolbarController = ItemsToolbarController(viewController: self, data: toolbarData, collection: collection, library: library, delegate: self)
5561
setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: viewModel.state))
5662
setupFileObservers()

Zotero/Scenes/Detail/Trash/Views/TrashViewController.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ final class TrashViewController: BaseItemsViewController {
3838
super.viewDidLoad()
3939

4040
dataSource = TrashTableViewDataSource(viewModel: viewModel, schemaController: controllers.schemaController, fileDownloader: controllers.userControllers?.fileDownloader)
41-
handler = ItemsTableViewHandler(tableView: tableView, delegate: self, dataSource: dataSource, dragDropController: controllers.dragDropController)
41+
handler = ItemsTableViewHandler(
42+
tableView: tableView,
43+
delegate: self,
44+
dataSource: dataSource,
45+
dragDropController: controllers.dragDropController,
46+
citationController: controllers.userControllers?.citationController
47+
)
4248
toolbarController = ItemsToolbarController(viewController: self, data: toolbarData, collection: collection, library: library, delegate: self)
4349
setupRightBarButtonItems(expectedItems: rightBarButtonItemTypes(for: viewModel.state))
4450
setupDownloadObserver()

Zotero/Scenes/Master/Collections/Views/ExpandableCollectionsCollectionViewHandler.swift

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,25 +260,24 @@ extension ExpandableCollectionsCollectionViewHandler: UICollectionViewDropDelega
260260
guard let indexPath = coordinator.destinationIndexPath, let key = dataSource.itemIdentifier(for: indexPath)?.identifier.key else { return }
261261
switch coordinator.proposal.operation {
262262
case .copy:
263-
dragDropController.keys(from: coordinator.items.map({ $0.dragItem })) { [weak self] keys in
264-
self?.viewModel.process(action: .assignKeysToCollection(itemKeys: keys, collectionKey: key))
265-
}
263+
guard let localContext = coordinator.session.localDragSession?.localContext as? DragSessionItemsLocalContext, !localContext.keys.isEmpty else { break }
264+
viewModel.process(action: .assignKeysToCollection(itemKeys: localContext.keys, collectionKey: key))
266265

267266
default:
268267
break
269268
}
270269
}
271270

272271
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
273-
guard
274-
viewModel.state.library.metadataEditable && // allow only when library is editable
275-
session.localDragSession != nil && // allow only local drag session
276-
session.items.compactMap({ $0.localObject as? RItem }).compactMap({ $0.libraryId }).first == viewModel.state.library.identifier // allow drag from the same library
272+
let library = viewModel.state.library
273+
guard library.metadataEditable,
274+
let localContext = session.localDragSession?.localContext as? DragSessionItemsLocalContext,
275+
localContext.libraryIdentifier == library.identifier,
276+
!localContext.keys.isEmpty,
277+
let destinationIndexPath,
278+
let collection = dataSource.itemIdentifier(for: destinationIndexPath),
279+
collection.identifier.isCollection
277280
else { return UICollectionViewDropProposal(operation: .forbidden) }
278-
// Allow only dropping to user collections, not custom collections, such as "All Items" or "My Publications"
279-
if let destination = destinationIndexPath, let collection = dataSource.itemIdentifier(for: destination), collection.identifier.isCollection {
280-
return UICollectionViewDropProposal(operation: .copy, intent: .insertIntoDestinationIndexPath)
281-
}
282-
return UICollectionViewDropProposal(operation: .forbidden)
281+
return UICollectionViewDropProposal(operation: .copy, intent: .insertIntoDestinationIndexPath)
283282
}
284283
}

Zotero/Scenes/Master/TagFiltering/Views/TagFilterViewController.swift

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,8 @@ class TagFilterViewController: UIViewController {
103103
self.collectionView = collectionView
104104
view.insertSubview(collectionView, belowSubview: searchContainer)
105105

106-
if traitCollection.horizontalSizeClass == .regular && UIDevice.current.userInterfaceIdiom == .pad {
107-
collectionView.dropDelegate = self
108-
} else {
106+
collectionView.dropDelegate = self
107+
if traitCollection.horizontalSizeClass != .regular || UIDevice.current.userInterfaceIdiom != .pad {
109108
collectionView.keyboardDismissMode = .onDrag
110109
}
111110

@@ -274,28 +273,23 @@ extension TagFilterViewController: UICollectionViewDataSource {
274273

275274
extension TagFilterViewController: UICollectionViewDropDelegate {
276275
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
277-
guard delegate?.currentLibrary.metadataEditable == true, // allow only when library is editable
278-
session.localDragSession != nil, // allow only local drag session
279-
let destinationIndexPath = destinationIndexPath,
280-
destinationIndexPath.row < self.collectionView(collectionView, numberOfItemsInSection: destinationIndexPath.section) else {
281-
return UICollectionViewDropProposal(operation: .forbidden)
282-
}
283-
284-
let dragItemsLibraryId = session.items.compactMap({ $0.localObject as? RItem }).compactMap({ $0.libraryId }).first
285-
if dragItemsLibraryId == delegate?.currentLibrary.identifier {
286-
return UICollectionViewDropProposal(operation: .copy, intent: .insertIntoDestinationIndexPath)
287-
}
288-
289-
return UICollectionViewDropProposal(operation: .forbidden)
276+
guard let library = delegate?.currentLibrary,
277+
library.metadataEditable,
278+
let localContext = session.localDragSession?.localContext as? DragSessionItemsLocalContext,
279+
localContext.libraryIdentifier == library.identifier,
280+
!localContext.keys.isEmpty,
281+
let destinationIndexPath,
282+
destinationIndexPath.row < viewModel.state.tags.count
283+
else { return UICollectionViewDropProposal(operation: .forbidden) }
284+
return UICollectionViewDropProposal(operation: .copy, intent: .insertIntoDestinationIndexPath)
290285
}
291286

292287
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
293-
guard let tag = coordinator.destinationIndexPath.flatMap({ tag(for: $0) }), let libraryId = (coordinator.items.first?.dragItem.localObject as? RItem)?.libraryId else { return }
288+
guard let indexPath = coordinator.destinationIndexPath, let tag = tag(for: indexPath) else { return }
294289
switch coordinator.proposal.operation {
295290
case .copy:
296-
dragDropController.keys(from: coordinator.items.map({ $0.dragItem })) { [weak viewModel] keys in
297-
viewModel?.process(action: .assignTag(name: tag.tag.name, toItemKeys: keys, libraryId: libraryId))
298-
}
291+
guard let localContext = coordinator.session.localDragSession?.localContext as? DragSessionItemsLocalContext, !localContext.keys.isEmpty else { break }
292+
viewModel.process(action: .assignTag(name: tag.tag.name, toItemKeys: localContext.keys, libraryId: localContext.libraryIdentifier))
299293

300294
default:
301295
break

0 commit comments

Comments
 (0)