1import UIKit
2
3/// A page's place in its section's order, as `sb_section_arrange` takes it.
4struct Placed: Codable, Equatable {
5 let id: String
6 var level: Int
7}
8
9/// Where `Arranging.move(page:)` took a page, from `sb_library_move_page`.
10private struct MovedPage: Decodable {
11 let page: String
12 let added: String?
13 let before: String?
14 let level: Int
15}
16
17/// The catalog path of the folder holding the section or group at `path`; "" for the
18/// notebook's top.
19func parentFolder(of path: String) -> String {
20 path.range(of: "/", options: .backwards).map { String(path[..<$0.lowerBound]) } ?? ""
21}
22
23private func json(_ value: some Encodable) -> String {
24 (try? JSONEncoder().encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
25}
26
27/// Moves of pages, sections, section groups and notebooks, each made as the desktop makes it,
28/// as one edit through the notebook, and kept for Undo: shake, three-finger swipe or ⌘Z in
29/// either list.
30enum Arranging {
31 static let history = UndoManager()
32 private static let owner = NSObject()
33
34 /// ⌘Z and ⇧⌘Z in a list, which, unlike a text view, has none of its own.
35 static let keyCommands = [
36 UIKeyCommand(title: "Undo", action: #selector(UIResponder.undoArranging), input: "z", modifierFlags: .command),
37 UIKeyCommand(title: "Redo", action: #selector(UIResponder.redoArranging), input: "z", modifierFlags: [.command, .shift]),
38 ]
39
40 /// Keeps `undo` as Undo for `name`. Called as the change starts, so that a change Undo
41 /// makes keeps its own undo as Redo.
42 private static func done(_ name: String, undo: @escaping () -> Void) {
43 history.registerUndo(withTarget: owner) { _ in undo() }
44 history.setActionName(name)
45 UIImpactFeedbackGenerator(style: .light).impactOccurred()
46 }
47
48 /// Runs `work` off the main thread after the changes before it, then `then` with its result
49 /// on the main thread.
50 private static func later<T>(_ work: @escaping () -> T, then: @escaping (T) -> Void) {
51 queue.async {
52 let result = work()
53 DispatchQueue.main.async { then(result) }
54 }
55 }
56 private static let queue = DispatchQueue(label: "Arranging")
57
58 private static func failed(_ title: String) {
59 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
60 var top = window?.rootViewController
61 while let presented = top?.presentedViewController { top = presented }
62 top?.alert(title, "Check that the notebook can be reached, then try again.")
63 }
64
65 /// The pages `rows` lists with page `id` moved to `index` of them, at its level or under
66 /// the page now above it, as the desktop drops a dragged page.
67 static func order(_ rows: [Row], moving id: String, to index: Int) -> [Placed] {
68 var order = rows.map { Placed(id: $0.id, level: $0.level) }
69 guard let from = order.firstIndex(where: { $0.id == id }) else { return order }
70 var page = order.remove(at: from)
71 let at = min(max(index, 0), order.count)
72 page.level = min(page.level, at == 0 ? 1 : order[at - 1].level + 1)
73 order.insert(page, at: at)
74 return order
75 }
76
77 /// Puts the pages of `section` in `order`, page `id` the one moved, as one edit.
78 static func arrange(_ section: Section, _ order: [Placed], moving id: String) {
79 let before = section.rows.map { Placed(id: $0.id, level: $0.level) }
80 guard order != before, section.arrange(order, moving: id) else { return }
81 let (notebook, path) = (section.notebook, section.tab.path)
82 done("Move Page") {
83 opening(path, of: notebook) { arrange($0, before, moving: id) }
84 }
85 }
86
87 /// Gives page `id` of `section` `level` where it stands, as Make Subpage and Promote
88 /// Subpage do.
89 static func indent(_ section: Section, _ id: String, to level: Int) {
90 var order = section.rows.map { Placed(id: $0.id, level: $0.level) }
91 guard let at = order.firstIndex(where: { $0.id == id }), order[at].level != level else { return }
92 let was = order[at].level
93 order[at].level = level
94 guard section.arrange(order, moving: id) else { return }
95 let (notebook, path) = (section.notebook, section.tab.path)
96 done(level > was ? "Make Subpage" : "Promote Subpage") {
97 opening(path, of: notebook) { indent($0, id, to: was) }
98 }
99 }
100
101 /// Hands `then` the section at `path` of `notebook`, opening it where it isn't open.
102 private static func opening(_ path: String, of notebook: Notebook, then: @escaping (Section) -> Void) {
103 guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return }
104 Section.open(tab, of: notebook) { section, _ in section.map(then) }
105 }
106
107 /// Moves page `id` of the section at `from` into the section at `to`, before `before` at
108 /// `level` (last without), as OneNote moves a page dropped on a section's tab.
109 static func move(
110 page id: String, of notebook: Notebook, from: String, to: String, before: String? = nil, level: Int = 1,
111 discard: String? = nil
112 ) {
113 guard let library = notebook.handle else { return }
114 let pointer = Int(bitPattern: library)
115 let author = Author.name ?? ""
116 let (date, time) = titleDate()
117 // Undo takes back what the move reports, once it has.
118 var reported: MovedPage?
119 done("Move Page") {
120 guard let moved = reported else { return }
121 move(
122 page: moved.page, of: notebook, from: to, to: from, before: moved.before, level: moved.level,
123 discard: moved.added)
124 }
125 later({
126 decode(
127 MovedPage.self,
128 sb_library_move_page(
129 OpaquePointer(bitPattern: pointer), from, id, to, before, UInt32(level), discard, author, date,
130 time))
131 }) { moved in
132 reported = moved
133 for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) {
134 section.listed()
135 }
136 if moved == nil { failed("Can’t Move Page") }
137 }
138 }
139
140 /// Puts the section or group at `path` of `notebook` into `folder`, ordered as `order`
141 /// names its entries once moved; a section shown opens again where it went.
142 static func place(_ path: String, of notebook: Notebook, in folder: String, order: [String] = []) {
143 guard let library = notebook.handle else { return }
144 let home = parentFolder(of: path)
145 let name = path.split(separator: "/").last.map(String.init) ?? path
146 let placed = home == folder ? path : folder.isEmpty ? name : folder + "/" + name
147 let undo = notebook.entries(in: home)
148 done("Move Section") { place(placed, of: notebook, in: home, order: undo) }
149 let scenes = UIApplication.shared.connectedScenes.compactMap { $0.delegate as? SceneDelegate }
150 let released = home == folder ? [] : scenes.compactMap { scene in scene.release(path, of: notebook).map { (scene, $0) } }
151 let pointer = Int(bitPattern: library)
152 let paths = json(order)
153 later({ take(sb_library_place(OpaquePointer(bitPattern: pointer), path, folder, paths)) != nil }) { moved in
154 notebook.reload {
155 NotificationCenter.default.post(name: Notebook.listed, object: notebook)
156 for (scene, shown) in released {
157 let section = moved ? follow(shown.section, from: path, to: placed) : shown.section
158 if let tab = notebook.tabs.first(where: { $0.path == section }) {
159 scene.open(tab, of: notebook, page: shown.page)
160 }
161 }
162 if !moved { failed("Can’t Move Section") }
163 }
164 }
165 }
166
167 /// `current`, a section's path, after the section or group at `from` went to `to`.
168 private static func follow(_ current: String, from: String, to: String) -> String {
169 current == from ? to : current.hasPrefix(from + "/") ? to + current.dropFirst(from.count) : current
170 }
171
172 /// Puts `notebook` at `index` among the notebooks of its place in the list.
173 static func move(_ notebook: Notebook, to index: Int) {
174 guard let from = Notebooks.siblings(of: notebook).firstIndex(where: { $0 === notebook }), from != index else {
175 return
176 }
177 Notebooks.move(notebook, to: index)
178 NotificationCenter.default.post(name: Notebook.listed, object: notebook)
179 done("Move Notebook") { move(notebook, to: from) }
180 }
181}
182
183extension UIResponder {
184 @objc func undoArranging() { Arranging.history.undo() }
185 @objc func redoArranging() { Arranging.history.redo() }
186}
187
188extension Section {
189 /// Puts page `id` where `order` places it, as one edit; false where it isn't listed.
190 func arrange(_ order: [Placed], moving id: String) -> Bool {
191 guard sb_section_arrange(handle, json(order), json([id])) else { return false }
192 listed()
193 return true
194 }
195
196 /// Lists the pages again and tells the views.
197 func listed() {
198 reloadRows()
199 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
200 }
201}
202
203extension Notebook {
204 /// The section groups, by catalog path, in the order the list shows them.
205 var groups: [String] {
206 var groups: [String] = []
207 for tab in tabs {
208 var path = ""
209 for part in tab.group.split(separator: "/") {
210 path += (path.isEmpty ? "" : "/") + part
211 if !groups.contains(path) { groups.append(path) }
212 }
213 }
214 return groups
215 }
216
217 /// The sections and groups directly in `folder`, by catalog path, as the list shows them.
218 func entries(in folder: String) -> [String] {
219 tabs.filter { $0.group == folder }.map(\.path) + groups.filter { parentFolder(of: $0) == folder }
220 }
221
222 /// Where the section or group at `path` can move: the notebook's top and its groups, by
223 /// catalog path, leaving out its own folder, itself and its groups.
224 func destinations(of path: String) -> [String] {
225 ([""] + groups).filter { $0 != parentFolder(of: path) && $0 != path && !$0.hasPrefix(path + "/") }
226 }
227
228 /// The sections a page of the section at `path` can move to.
229 func sections(besides path: String) -> [Tab] {
230 tabs.filter { $0.path != path && $0.readable && !$0.locked }
231 }
232}
233
234/// A page dragged out of the page list.
235final class PageDrag {
236 let section: Section
237 let id: String
238
239 init(section: Section, id: String) {
240 self.section = section
241 self.id = id
242 }
243}
244
245/// A sheet offering `actions`, for VoiceOver’s Move actions, from `view`.
246private func sheet(_ title: String, _ actions: [UIAction], from view: UIView, in controller: UIViewController) {
247 let sheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
248 for action in actions {
249 sheet.addAction(UIAlertAction(title: action.title, style: .default) { _ in action.performWithSender(nil, target: nil) })
250 }
251 sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
252 sheet.popoverPresentationController?.sourceView = view
253 sheet.popoverPresentationController?.sourceRect = view.bounds
254 controller.present(sheet, animated: true)
255}
256
257// MARK: Pages
258
259extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate {
260 func setUpArranging() {
261 tableView.dragDelegate = self
262 tableView.dropDelegate = self
263 tableView.dragInteractionEnabled = true
264 navigationItem.rightBarButtonItem = editButtonItem
265 }
266
267 override var canBecomeFirstResponder: Bool { true }
268 override var undoManager: UndoManager? { Arranging.history }
269 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }
270
271 /// Undo in the list takes back its moves.
272 override func viewDidAppear(_ animated: Bool) {
273 super.viewDidAppear(animated)
274 becomeFirstResponder()
275 }
276
277 /// Page `id` moved to `index` of the section's pages.
278 private func move(_ id: String, to index: Int) {
279 guard let section else { return }
280 Arranging.arrange(section, Arranging.order(section.rows, moving: id, to: index), moving: id)
281 becomeFirstResponder()
282 }
283
284 /// Page `id` a level deeper or shallower, where it stays.
285 private func indent(_ id: String, by change: Int) {
286 guard let section, let row = section.rows.first(where: { $0.id == id }) else { return }
287 Arranging.indent(section, id, to: row.level + change)
288 becomeFirstResponder()
289 }
290
291 /// Whether page `at` of `rows` can become a subpage, as the desktop allows: under a page
292 /// at its level or deeper, at most three deep.
293 private func indentable(_ rows: [Row], _ at: Int) -> Bool {
294 at > 0 && rows[at].level < 3 && rows[at - 1].level >= rows[at].level
295 }
296
297 private func moveActions(_ item: Item) -> [UIAction] {
298 guard let section else { return [] }
299 return section.notebook.sections(besides: section.tab.path).map { tab in
300 UIAction(
301 title: tab.name, subtitle: tab.group.isEmpty ? nil : tab.group,
302 image: UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(tab.uiColor, renderingMode: .alwaysOriginal)
303 ) { [weak self] _ in
304 Arranging.move(page: item.row.id, of: section.notebook, from: section.tab.path, to: tab.path)
305 self?.becomeFirstResponder()
306 }
307 }
308 }
309
310 /// Make Subpage, Promote Subpage and Move to Section, for a page's menu.
311 func arrangeMenu(_ item: Item) -> [UIMenuElement] {
312 guard let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id }) else { return [] }
313 let id = item.row.id
314 var actions: [UIMenuElement] = [
315 UIAction(
316 title: "Make Subpage", image: UIImage(systemName: "increase.indent"),
317 attributes: indentable(rows, at) ? [] : .disabled
318 ) { [weak self] _ in self?.indent(id, by: 1) },
319 UIAction(
320 title: "Promote Subpage", image: UIImage(systemName: "decrease.indent"),
321 attributes: rows[at].level > 1 ? [] : .disabled
322 ) { [weak self] _ in self?.indent(id, by: -1) },
323 ]
324 let moves = moveActions(item)
325 if !moves.isEmpty {
326 actions.append(UIMenu(title: "Move to Section", image: UIImage(systemName: "arrow.right.doc.on.clipboard"), children: moves))
327 }
328 return [UIMenu(options: .displayInline, children: actions)]
329 }
330
331 /// VoiceOver's ways to move a page without dragging it.
332 func arrangeActions(_ item: Item) -> [UIAccessibilityCustomAction]? {
333 guard item.version == nil, let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id })
334 else { return nil }
335 let id = item.row.id
336 var actions: [UIAccessibilityCustomAction] = []
337 let add = { (name: String, act: @escaping () -> Void) in
338 actions.append(UIAccessibilityCustomAction(name: name) { _ in act(); return true })
339 }
340 if at > 0 { add("Move Up") { [weak self] in self?.move(id, to: at - 1) } }
341 if at < rows.count - 1 { add("Move Down") { [weak self] in self?.move(id, to: at + 1) } }
342 if indentable(rows, at) { add("Make Subpage") { [weak self] in self?.indent(id, by: 1) } }
343 if rows[at].level > 1 { add("Promote Subpage") { [weak self] in self?.indent(id, by: -1) } }
344 if !moveActions(item).isEmpty {
345 add("Move to Section") { [weak self] in
346 guard let self, let row = items.firstIndex(where: { $0.id == id }),
347 let cell = tableView.cellForRow(at: IndexPath(row: row, section: 0))
348 else { return }
349 sheet("Move to Section", moveActions(item), from: cell, in: self)
350 }
351 }
352 return actions
353 }
354
355 override func tableView(
356 _ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath
357 ) -> UISwipeActionsConfiguration? {
358 // A swipe across the list changes section instead.
359 guard !Prototype.sectionStrip, let rows = section?.rows, items[indexPath.row].version == nil,
360 let at = rows.firstIndex(where: { $0.id == items[indexPath.row].row.id })
361 else { return nil }
362 let id = rows[at].id
363 var actions: [UIContextualAction] = []
364 if indentable(rows, at) {
365 let indent = UIContextualAction(style: .normal, title: "Make Subpage") { [weak self] _, _, done in
366 self?.indent(id, by: 1)
367 done(true)
368 }
369 indent.image = UIImage(systemName: "increase.indent")
370 indent.backgroundColor = .systemIndigo
371 actions.append(indent)
372 }
373 if rows[at].level > 1 {
374 let promote = UIContextualAction(style: .normal, title: "Promote Subpage") { [weak self] _, _, done in
375 self?.indent(id, by: -1)
376 done(true)
377 }
378 promote.image = UIImage(systemName: "decrease.indent")
379 promote.backgroundColor = .systemTeal
380 actions.append(promote)
381 }
382 return UISwipeActionsConfiguration(actions: actions)
383 }
384
385 override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
386 items[indexPath.row].version == nil
387 }
388
389 override func tableView(_ tableView: UITableView, moveRowAt from: IndexPath, to: IndexPath) {
390 var moved = items
391 let item = moved.remove(at: from.row)
392 moved.insert(item, at: to.row)
393 guard let index = moved.filter({ $0.version == nil }).firstIndex(where: { $0.id == item.id }) else { return }
394 // The table finishes its move before the list reloads.
395 DispatchQueue.main.async { [weak self] in self?.move(item.id, to: index) }
396 }
397
398 override func tableView(
399 _ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath
400 ) -> UITableViewCell.EditingStyle {
401 tableView.isEditing ? .none : .delete
402 }
403
404 override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
405 false
406 }
407
408 func tableView(
409 _ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath
410 ) -> [UIDragItem] {
411 let item = items[indexPath.row]
412 guard let section, item.version == nil else { return [] }
413 let drag = UIDragItem(itemProvider: NSItemProvider())
414 drag.localObject = PageDrag(section: section, id: item.row.id)
415 return [drag]
416 }
417
418 func tableView(_ tableView: UITableView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool {
419 true
420 }
421
422 /// A wide window shows the notebooks beside the pages while a page is dragged, so it can
423 /// be dropped on a section there.
424 func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession) {
425 guard let split = splitViewController, !split.isCollapsed, split.displayMode != .twoBesideSecondary else { return }
426 session.localContext = split.preferredDisplayMode
427 split.preferredDisplayMode = .twoBesideSecondary
428 }
429
430 func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) {
431 guard let mode = session.localContext as? UISplitViewController.DisplayMode else { return }
432 splitViewController?.preferredDisplayMode = mode
433 }
434
435 func tableView(
436 _ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath: IndexPath?
437 ) -> UITableViewDropProposal {
438 let page = session.localDragSession?.items.first?.localObject as? PageDrag
439 guard let page, page.section === section else {
440 return UITableViewDropProposal(operation: .forbidden)
441 }
442 return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
443 }
444
445 /// Drops within the list reorder through `moveRowAt`.
446 func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {}
447}
448
449// MARK: Notebooks, sections and groups
450
451extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionViewDropDelegate {
452 func setUpArranging() {
453 collectionView.dragDelegate = self
454 collectionView.dropDelegate = self
455 collectionView.dragInteractionEnabled = true
456 // A sidebar's trailing edge has no room left for it.
457 if UIDevice.current.userInterfaceIdiom == .pad {
458 navigationItem.leftBarButtonItem = editButtonItem
459 } else {
460 navigationItem.rightBarButtonItems?.append(editButtonItem)
461 }
462 // Rows dragged between rows, and by Edit's reorder handles, move through the data source.
463 dataSource.reorderingHandlers.canReorderItem = { [weak self] item in self?.movable(item) == true }
464 // The data source is still applying the move when this runs.
465 dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
466 DispatchQueue.main.async { self?.reordered(transaction) }
467 }
468 }
469
470 override var canBecomeFirstResponder: Bool { true }
471 override var undoManager: UndoManager? { Arranging.history }
472 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }
473
474 /// Undo in the list takes back its moves.
475 override func viewDidAppear(_ animated: Bool) {
476 super.viewDidAppear(animated)
477 becomeFirstResponder()
478 }
479
480 override func setEditing(_ editing: Bool, animated: Bool) {
481 super.setEditing(editing, animated: animated)
482 collectionView.isEditing = editing
483 collectionView.dragInteractionEnabled = !editing
484 }
485
486 private func movable(_ item: Item) -> Bool {
487 switch item {
488 case .notebook, .group, .section: true
489 default: false
490 }
491 }
492
493 /// The notebook and catalog path of a section or group row.
494 private func entry(_ item: Item) -> (notebook: Notebook, path: String)? {
495 switch item {
496 case .group(let id, let path), .section(let id, let path): notebook(id).map { ($0, path) }
497 default: nil
498 }
499 }
500
501 /// The group or notebook top a row stands for, as a folder an entry of `notebook` can go into.
502 private func folder(_ item: Item, in notebook: Notebook) -> String? {
503 switch item {
504 case .notebook(let id) where id == notebook.id: ""
505 case .group(let id, let path) where id == notebook.id: path
506 default: nil
507 }
508 }
509
510 /// Moves the section or group at `path` within its folder by `offset`.
511 private func shift(_ path: String, of notebook: Notebook, by offset: Int) {
512 var order = notebook.entries(in: parentFolder(of: path))
513 guard let at = order.firstIndex(of: path), order.indices.contains(at + offset) else { return }
514 order.swapAt(at, at + offset)
515 Arranging.place(path, of: notebook, in: parentFolder(of: path), order: order)
516 becomeFirstResponder()
517 }
518
519 private func moveActions(_ path: String, of notebook: Notebook) -> [UIAction] {
520 notebook.destinations(of: path).map { folder in
521 UIAction(
522 title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › "),
523 image: UIImage(systemName: folder.isEmpty ? "book.closed" : "folder")
524 ) { [weak self] _ in
525 Arranging.place(path, of: notebook, in: folder)
526 self?.becomeFirstResponder()
527 }
528 }
529 }
530
531 /// Move, into the notebook's top or a group, for a section's or group's menu; Move Up and
532 /// Move Down for a notebook's, as the desktop's.
533 func arrangeMenu(_ item: Item) -> [UIMenuElement] {
534 if case .notebook(let id) = item, let notebook = notebook(id) {
535 let siblings = Notebooks.siblings(of: notebook)
536 guard let at = siblings.firstIndex(where: { $0 === notebook }), siblings.count > 1 else { return [] }
537 return [
538 UIMenu(options: .displayInline, children: [
539 UIAction(title: "Move Up", image: UIImage(systemName: "arrow.up"), attributes: at > 0 ? [] : .disabled) {
540 _ in Arranging.move(notebook, to: at - 1)
541 },
542 UIAction(
543 title: "Move Down", image: UIImage(systemName: "arrow.down"),
544 attributes: at < siblings.count - 1 ? [] : .disabled
545 ) { _ in Arranging.move(notebook, to: at + 1) },
546 ])
547 ]
548 }
549 guard let (notebook, path) = entry(item) else { return [] }
550 let moves = moveActions(path, of: notebook)
551 return moves.isEmpty ? [] : [UIMenu(title: "Move", image: UIImage(systemName: "folder"), children: moves)]
552 }
553
554 /// Adds Edit's reorder handle and VoiceOver's ways to move to a row.
555 func arranging(_ cell: UICollectionViewListCell, _ item: Item) {
556 if movable(item) { cell.accessories.append(.reorder(displayed: .whenEditing)) }
557 var actions: [UIAccessibilityCustomAction] = []
558 let add = { (name: String, act: @escaping () -> Void) in
559 actions.append(UIAccessibilityCustomAction(name: name) { _ in act(); return true })
560 }
561 if case .notebook(let id) = item, let notebook = notebook(id) {
562 let siblings = Notebooks.siblings(of: notebook)
563 if let at = siblings.firstIndex(where: { $0 === notebook }) {
564 if at > 0 { add("Move Up") { Arranging.move(notebook, to: at - 1) } }
565 if at < siblings.count - 1 { add("Move Down") { Arranging.move(notebook, to: at + 1) } }
566 }
567 } else if let (notebook, path) = entry(item) {
568 let order = notebook.entries(in: parentFolder(of: path))
569 if let at = order.firstIndex(of: path) {
570 if at > 0 { add("Move Up") { [weak self] in self?.shift(path, of: notebook, by: -1) } }
571 if at < order.count - 1 { add("Move Down") { [weak self] in self?.shift(path, of: notebook, by: 1) } }
572 }
573 if !notebook.destinations(of: path).isEmpty {
574 add("Move") { [weak self, weak cell] in
575 guard let self, let cell else { return }
576 sheet("Move", moveActions(path, of: notebook), from: cell, in: self)
577 }
578 }
579 }
580 cell.accessibilityCustomActions = actions
581 }
582
583 /// Carries out a move the data source made: a notebook among its place's, or a
584 /// section or group into the folder it landed in; anywhere else it goes back.
585 private func reordered(_ transaction: NSDiffableDataSourceTransaction<Location, Item>) {
586 becomeFirstResponder()
587 let moved = transaction.difference.insertions.lazy.compactMap { change -> Item? in
588 if case .insert(_, let item, _) = change { return item }
589 return nil
590 }.first
591 guard let moved, let list = transaction.sectionTransactions.first?.finalSnapshot,
592 let parent = list.parent(of: moved)
593 else { return reload() }
594 let siblings = list.snapshot(of: parent).rootItems
595 if case .notebook(let id) = moved, let notebook = notebook(id), case .location = parent {
596 let notebooks = siblings.filter { if case .notebook = $0 { true } else { false } }
597 return Arranging.move(notebook, to: notebooks.firstIndex(of: moved) ?? 0)
598 }
599 guard let (notebook, path) = entry(moved), let folder = folder(parent, in: notebook),
600 folder != path, !folder.hasPrefix(path + "/")
601 else { return reload() }
602 let name = path.split(separator: "/").last.map(String.init) ?? path
603 let order = siblings.compactMap { sibling in
604 sibling == moved ? (folder.isEmpty ? name : folder + "/" + name) : entry(sibling)?.path
605 }
606 Arranging.place(path, of: notebook, in: folder, order: order)
607 }
608
609 func collectionView(
610 _ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath
611 ) -> [UIDragItem] {
612 guard let item = dataSource.itemIdentifier(for: indexPath), movable(item) else { return [] }
613 let drag = UIDragItem(itemProvider: NSItemProvider())
614 drag.localObject = item
615 return [drag]
616 }
617
618 func collectionView(
619 _ collectionView: UICollectionView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession
620 ) -> Bool {
621 true
622 }
623
624 func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool {
625 session.localDragSession != nil
626 }
627
628 /// The row under `session`'s touch.
629 private func row(under session: UIDropSession) -> (IndexPath, Item, UICollectionViewCell)? {
630 let point = session.location(in: collectionView)
631 guard let indexPath = collectionView.indexPathForItem(at: point),
632 let item = dataSource.itemIdentifier(for: indexPath), let cell = collectionView.cellForItem(at: indexPath)
633 else { return nil }
634 return (indexPath, item, cell)
635 }
636
637 /// The section a dragged page would move to, under the touch.
638 private func section(for page: PageDrag, under session: UIDropSession) -> Tab? {
639 guard let (_, item, _) = row(under: session), case .section(let id, let path) = item,
640 id == page.section.notebook.id
641 else { return nil }
642 return page.section.notebook.sections(besides: page.section.tab.path).first { $0.path == path }
643 }
644
645 func collectionView(
646 _ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession,
647 withDestinationIndexPath destinationIndexPath: IndexPath?
648 ) -> UICollectionViewDropProposal {
649 let dragged = session.localDragSession?.items.first?.localObject
650 if let page = dragged as? PageDrag {
651 guard section(for: page, under: session) != nil else { return UICollectionViewDropProposal(operation: .forbidden) }
652 return UICollectionViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath)
653 }
654 guard dragged is Item else { return UICollectionViewDropProposal(operation: .forbidden) }
655 // A row dropped between rows moves through the data source's reordering handlers.
656 return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
657 }
658
659 func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
660 guard let drop = coordinator.items.first else { return }
661 let session = coordinator.session
662 guard let page = drop.dragItem.localObject as? PageDrag, let tab = section(for: page, under: session),
663 let (indexPath, _, cell) = row(under: session)
664 else { return }
665 coordinator.drop(drop.dragItem, intoItemAt: indexPath, rect: cell.bounds)
666 Arranging.move(page: page.id, of: page.section.notebook, from: page.section.tab.path, to: tab.path)
667 becomeFirstResponder()
668 }
669}