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