| 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 | /// 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. |
| 21 | func parentFolder(of path: String) -> String { |
| 22 | path.range(of: "/", options: .backwards).map { String(path[..<$0.lowerBound]) } ?? "" |
| 23 | } |
| 24 | |
| 25 | private 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. |
| 32 | enum 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 | |
| 305 | extension UIResponder { |
| 306 | @objc func undoArranging() { if !Arranging.busy { Arranging.history.undo() } } |
| 307 | @objc func redoArranging() { if !Arranging.busy { Arranging.history.redo() } } |
| 308 | } |
| 309 | |
| 310 | extension 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 | |
| 319 | extension 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. |
| 351 | final 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`. |
| 362 | private 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 | |
| 377 | extension 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 | |
| 579 | extension 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 | } |