| 1 | import UIKit |
| 2 | |
| 3 | /// A page's place in its section's order, as `sb_section_arrange` takes it. |
| 4 | struct 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`. |
| 10 | private 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. |
| 19 | func parentFolder(of path: String) -> String { |
| 20 | path.range(of: "/", options: .backwards).map { String(path[..<$0.lowerBound]) } ?? "" |
| 21 | } |
| 22 | |
| 23 | private 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. |
| 30 | enum 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 | |
| 183 | extension UIResponder { |
| 184 | @objc func undoArranging() { Arranging.history.undo() } |
| 185 | @objc func redoArranging() { Arranging.history.redo() } |
| 186 | } |
| 187 | |
| 188 | extension 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 | |
| 203 | extension 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. |
| 235 | final 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`. |
| 246 | private 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 | |
| 259 | extension 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 | |
| 451 | extension 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 | } |