| author | |
| committer | |
| log | 218d66206d78eb0a122f2a2ab226c0b7a232dcfd |
| tree | 8712631c145de2ee8ae3e5414bde9ee2b0a35e6d |
| parent | 90fff4d83a2857efc8507af9ae1fbaded1652925 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Pages drag to a new place in their section, and swipe or the context menu
makes one a subpage or promotes it; Move to Section, or a drop on a section
in the sidebar, moves one to another section, as the desktop moves a page
dropped on a section tab. Sections and groups drag within their notebook or
into a group, or go through Move; notebooks drag within their place in the
list, which keeps the order. Edit shows reorder handles, and VoiceOver offers
Move Up, Move Down, Make Subpage, Promote Subpage and Move as actions.
Each move is the desktop's edit through crates/mobile: sb_section_arrange
applies notebook::session::arrange, now shared with the desktop's Undo, as one
revision; sb_library_move_page imports the page into the other section and
deletes it from its own; sb_library_place moves and orders TOC entries with
Notebook::move_entry and reorder. Undo and Redo (shake, three-finger swipe,
Command-Z in a list) take each move back the same way.
Assisted-by: claude-opus-5.510 files changed, 1212 insertions(+), 50 deletions(-)
apps/ios/Snowbound/App.swift+11| ... | @@ -287,6 +287,17 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro | ... | @@ -287,6 +287,17 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 287 | split.show(.primary) | 287 | split.show(.primary) |
| 288 | } | 288 | } |
| 289 | 289 | ||
| 290 | /// Lets go of the section shown where it is the section or group at `path` of `notebook` | ||
| 291 | /// or within it, as before its file moves; its path and page, to open once moved. | ||
| 292 | func release(_ path: String, of notebook: Notebook) -> (section: String, page: String?)? { | ||
| 293 | guard let section = pages.section, section.notebook === notebook, | ||
| 294 | section.tab.path == path || section.tab.path.hasPrefix(path + "/") | ||
| 295 | else { return nil } | ||
| 296 | let shown = (section.tab.path, Place.saved?.page) | ||
| 297 | close(notebook) | ||
| 298 | return shown | ||
| 299 | } | ||
| 300 | |||
| 290 | /// Adds a page to the open section and opens it, its title ready for typing. | 301 | /// Adds a page to the open section and opens it, its title ready for typing. |
| 291 | func newPage(subpage: Bool = false) { | 302 | func newPage(subpage: Bool = false) { |
| 292 | guard let section = pages.section else { return } | 303 | guard let section = pages.section else { return } |
apps/ios/Snowbound/Arranging.swift created+669| ... | @@ -0,0 +1,669 @@ | ||
| 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 | } | ||
apps/ios/Snowbound/Lists.swift+28-16| ... | @@ -4,7 +4,7 @@ import UniformTypeIdentifiers | ... | @@ -4,7 +4,7 @@ import UniformTypeIdentifiers |
| 4 | /// The places notebooks are kept, each listing its notebooks with their sections, as Files | 4 | /// The places notebooks are kept, each listing its notebooks with their sections, as Files |
| 5 | /// lists its locations and Notes each account's folders. | 5 | /// lists its locations and Notes each account's folders. |
| 6 | final class NotebooksViewController: UICollectionViewController, UIDocumentPickerDelegate { | 6 | final class NotebooksViewController: UICollectionViewController, UIDocumentPickerDelegate { |
| 7 | private enum Location: Hashable { | 7 | enum Location: Hashable { |
| 8 | /// Snowbound's folder in iCloud Drive, where new notebooks go while iCloud Drive is on. | 8 | /// Snowbound's folder in iCloud Drive, where new notebooks go while iCloud Drive is on. |
| 9 | case icloud | 9 | case icloud |
| 10 | /// Snowbound's Documents, which Files shows as Snowbound's folder. | 10 | /// Snowbound's Documents, which Files shows as Snowbound's folder. |
| ... | @@ -13,7 +13,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -13,7 +13,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 13 | case elsewhere | 13 | case elsewhere |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | private enum Item: Hashable { | 16 | enum Item: Hashable { |
| 17 | case location(Location) | 17 | case location(Location) |
| 18 | case notebook(String) | 18 | case notebook(String) |
| 19 | /// A section group, by its `/`-separated path. | 19 | /// A section group, by its `/`-separated path. |
| ... | @@ -23,7 +23,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -23,7 +23,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 23 | case status(notebook: String) | 23 | case status(notebook: String) |
| 24 | } | 24 | } |
| 25 | 25 | ||
| 26 | private var dataSource: UICollectionViewDiffableDataSource<Location, Item>! | 26 | var dataSource: UICollectionViewDiffableDataSource<Location, Item>! |
| 27 | private var collapsed: Set<Item> = [] | 27 | private var collapsed: Set<Item> = [] |
| 28 | /// What each notebook's row shows of its sync status, by notebook id. | 28 | /// What each notebook's row shows of its sync status, by notebook id. |
| 29 | private var attention: [String: Sync.Attention] = [:] | 29 | private var attention: [String: Sync.Attention] = [:] |
| ... | @@ -63,6 +63,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -63,6 +63,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 63 | } | 63 | } |
| 64 | let cell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [weak self] cell, _, item in | 64 | let cell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [weak self] cell, _, item in |
| 65 | self?.configure(cell, item) | 65 | self?.configure(cell, item) |
| 66 | self?.arranging(cell, item) | ||
| 66 | } | 67 | } |
| 67 | dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { view, indexPath, item in | 68 | dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { view, indexPath, item in |
| 68 | view.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: item) | 69 | view.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: item) |
| ... | @@ -138,6 +139,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -138,6 +139,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 138 | UIBarButtonItem(title: "More", image: UIImage(systemName: "ellipsis"), menu: more), | 139 | UIBarButtonItem(title: "More", image: UIImage(systemName: "ellipsis"), menu: more), |
| 139 | ] | 140 | ] |
| 140 | navigationItem.searchController = SearchViewController.controller() | 141 | navigationItem.searchController = SearchViewController.controller() |
| 142 | setUpArranging() | ||
| 141 | let corner = Prototype.quickNote ? [QuickNote.item()] : [] | 143 | let corner = Prototype.quickNote ? [QuickNote.item()] : [] |
| 142 | if #available(iOS 26, *) { | 144 | if #available(iOS 26, *) { |
| 143 | // Search sits in the bottom toolbar, as Notes and Files put it. | 145 | // Search sits in the bottom toolbar, as Notes and Files put it. |
| ... | @@ -151,7 +153,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -151,7 +153,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 151 | } | 153 | } |
| 152 | NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in | 154 | NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in |
| 153 | guard let self else { return } | 155 | guard let self else { return } |
| 154 | reload() | 156 | reload(animated: true) |
| 155 | var shown = dataSource.snapshot() | 157 | var shown = dataSource.snapshot() |
| 156 | shown.reconfigureItems(shown.itemIdentifiers) | 158 | shown.reconfigureItems(shown.itemIdentifiers) |
| 157 | dataSource.apply(shown, animatingDifferences: false) | 159 | dataSource.apply(shown, animatingDifferences: false) |
| ... | @@ -165,9 +167,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -165,9 +167,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 165 | navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false) | 167 | navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false) |
| 166 | } | 168 | } |
| 167 | 169 | ||
| 168 | private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } } | 170 | func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } } |
| 169 | 171 | ||
| 170 | func reload() { | 172 | func reload(animated: Bool = false) { |
| 171 | guard isViewLoaded else { return } | 173 | guard isViewLoaded else { return } |
| 172 | if Prototype.welcome { | 174 | if Prototype.welcome { |
| 173 | let welcome = Prototype.welcome( | 175 | let welcome = Prototype.welcome( |
| ... | @@ -194,9 +196,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -194,9 +196,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 194 | } else { | 196 | } else { |
| 195 | contentUnavailableConfiguration = nil | 197 | contentUnavailableConfiguration = nil |
| 196 | } | 198 | } |
| 197 | var sections = NSDiffableDataSourceSnapshot<Location, Item>() | 199 | // Locations kept keep their rows, so a move animates. |
| 198 | sections.appendSections(locations.map(\.0)) | 200 | if dataSource.snapshot().sectionIdentifiers != locations.map(\.0) { |
| 199 | dataSource.apply(sections, animatingDifferences: false) | 201 | var sections = NSDiffableDataSourceSnapshot<Location, Item>() |
| 202 | sections.appendSections(locations.map(\.0)) | ||
| 203 | dataSource.apply(sections, animatingDifferences: false) | ||
| 204 | } | ||
| 200 | for (location, notebooks) in locations { | 205 | for (location, notebooks) in locations { |
| 201 | var list = NSDiffableDataSourceSectionSnapshot<Item>() | 206 | var list = NSDiffableDataSourceSectionSnapshot<Item>() |
| 202 | let header = Item.location(location) | 207 | let header = Item.location(location) |
| ... | @@ -221,7 +226,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -221,7 +226,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 221 | } | 226 | } |
| 222 | } | 227 | } |
| 223 | list.expand(list.items.filter { !collapsed.contains($0) }) | 228 | list.expand(list.items.filter { !collapsed.contains($0) }) |
| 224 | dataSource.apply(list, to: location, animatingDifferences: false) | 229 | dataSource.apply(list, to: location, animatingDifferences: animated) |
| 225 | } | 230 | } |
| 226 | showSelection() | 231 | showSelection() |
| 227 | refreshSync() | 232 | refreshSync() |
| ... | @@ -583,10 +588,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -583,10 +588,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 583 | override func collectionView( | 588 | override func collectionView( |
| 584 | _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint | 589 | _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint |
| 585 | ) -> UIContextMenuConfiguration? { | 590 | ) -> UIContextMenuConfiguration? { |
| 586 | guard indexPaths.count == 1, case .notebook(let id) = dataSource.itemIdentifier(for: indexPaths[0]), | 591 | guard indexPaths.count == 1, let item = dataSource.itemIdentifier(for: indexPaths[0]) else { return nil } |
| 587 | let notebook = notebook(id) | 592 | guard case .notebook(let id) = item, let notebook = notebook(id) else { |
| 588 | else { return nil } | 593 | let moves = arrangeMenu(item) |
| 589 | return UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.menu(for: notebook) }) | 594 | return moves.isEmpty ? nil : UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: moves) }) |
| 595 | } | ||
| 596 | return UIContextMenuConfiguration(actionProvider: { [weak self] _ in | ||
| 597 | UIMenu(children: (self?.menu(for: notebook).children ?? []) + (self?.arrangeMenu(item) ?? [])) | ||
| 598 | }) | ||
| 590 | } | 599 | } |
| 591 | 600 | ||
| 592 | override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { | 601 | override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { |
| ... | @@ -807,14 +816,14 @@ extension UIFont { | ... | @@ -807,14 +816,14 @@ extension UIFont { |
| 807 | 816 | ||
| 808 | /// A section's pages, subpages indented, each page's conflict pages beneath it. | 817 | /// A section's pages, subpages indented, each page's conflict pages beneath it. |
| 809 | final class PagesViewController: UITableViewController { | 818 | final class PagesViewController: UITableViewController { |
| 810 | private struct Item { | 819 | struct Item { |
| 811 | let row: Row | 820 | let row: Row |
| 812 | let version: Row.Version? | 821 | let version: Row.Version? |
| 813 | var id: String { version?.id ?? row.id } | 822 | var id: String { version?.id ?? row.id } |
| 814 | } | 823 | } |
| 815 | 824 | ||
| 816 | private(set) var section: Section? | 825 | private(set) var section: Section? |
| 817 | private var items: [Item] = [] | 826 | private(set) var items: [Item] = [] |
| 818 | private var selected: String? | 827 | private var selected: String? |
| 819 | private lazy var search = SearchViewController.controller { [weak self] in self?.section } | 828 | private lazy var search = SearchViewController.controller { [weak self] in self?.section } |
| 820 | /// Opens a page of the section. | 829 | /// Opens a page of the section. |
| ... | @@ -847,6 +856,7 @@ final class PagesViewController: UITableViewController { | ... | @@ -847,6 +856,7 @@ final class PagesViewController: UITableViewController { |
| 847 | } | 856 | } |
| 848 | NotificationCenter.default.addObserver( | 857 | NotificationCenter.default.addObserver( |
| 849 | self, selector: #selector(changed), name: Section.changed, object: nil) | 858 | self, selector: #selector(changed), name: Section.changed, object: nil) |
| 859 | setUpArranging() | ||
| 850 | if Prototype.sectionStrip { | 860 | if Prototype.sectionStrip { |
| 851 | for direction in [UISwipeGestureRecognizer.Direction.left, .right] { | 861 | for direction in [UISwipeGestureRecognizer.Direction.left, .right] { |
| 852 | let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swiped)) | 862 | let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swiped)) |
| ... | @@ -981,6 +991,7 @@ final class PagesViewController: UITableViewController { | ... | @@ -981,6 +991,7 @@ final class PagesViewController: UITableViewController { |
| 981 | } | 991 | } |
| 982 | } | 992 | } |
| 983 | cell.contentConfiguration = content | 993 | cell.contentConfiguration = content |
| 994 | cell.accessibilityCustomActions = arrangeActions(item) | ||
| 984 | return cell | 995 | return cell |
| 985 | } | 996 | } |
| 986 | 997 | ||
| ... | @@ -1014,6 +1025,7 @@ final class PagesViewController: UITableViewController { | ... | @@ -1014,6 +1025,7 @@ final class PagesViewController: UITableViewController { |
| 1014 | actions.append(UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in | 1025 | actions.append(UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in |
| 1015 | self?.newPage(under: item.row.id) | 1026 | self?.newPage(under: item.row.id) |
| 1016 | }) | 1027 | }) |
| 1028 | actions += self?.arrangeMenu(item) ?? [] | ||
| 1017 | } | 1029 | } |
| 1018 | actions.append( | 1030 | actions.append( |
| 1019 | UIAction( | 1031 | UIAction( |
apps/ios/Snowbound/Notebooks.swift+38-9| ... | @@ -447,6 +447,8 @@ enum Notebooks { | ... | @@ -447,6 +447,8 @@ enum Notebooks { |
| 447 | 447 | ||
| 448 | private static let key = "notebooks" | 448 | private static let key = "notebooks" |
| 449 | private static let hidden = "hidesOnDevice" | 449 | private static let hidden = "hidesOnDevice" |
| 450 | /// The notebooks' ids in the order the reader put them in. | ||
| 451 | private static let order = "notebookOrder" | ||
| 450 | /// The Snowbound Guide's folder, in the app and in Documents once copied there. | 452 | /// The Snowbound Guide's folder, in the app and in Documents once copied there. |
| 451 | static let guide = "Snowbound Guide" | 453 | static let guide = "Snowbound Guide" |
| 452 | /// Whether the list offers the guide; off until Clover has read it through. | 454 | /// Whether the list offers the guide; off until Clover has read it through. |
| ... | @@ -478,13 +480,13 @@ enum Notebooks { | ... | @@ -478,13 +480,13 @@ enum Notebooks { |
| 478 | if let data = UserDefaults.standard.data(forKey: key), | 480 | if let data = UserDefaults.standard.data(forKey: key), |
| 479 | let entries = try? JSONDecoder().decode([Entry].self, from: data) | 481 | let entries = try? JSONDecoder().decode([Entry].self, from: data) |
| 480 | { | 482 | { |
| 481 | elsewhere = entries.compactMap { entry in | 483 | elsewhere = ordered(entries.compactMap { entry in |
| 482 | switch entry.source { | 484 | switch entry.source { |
| 483 | case .documents, .icloud: return nil | 485 | case .documents, .icloud: return nil |
| 484 | default: break | 486 | default: break |
| 485 | } | 487 | } |
| 486 | return Notebook(id: entry.id, name: entry.name, source: entry.source) | 488 | return Notebook(id: entry.id, name: entry.name, source: entry.source) |
| 487 | } | 489 | }) |
| 488 | } else { | 490 | } else { |
| 489 | // The notebook the first version remembered from Files. | 491 | // The notebook the first version remembered from Files. |
| 490 | if let bookmark = UserDefaults.standard.data(forKey: "notebook") { | 492 | if let bookmark = UserDefaults.standard.data(forKey: "notebook") { |
| ... | @@ -522,16 +524,43 @@ enum Notebooks { | ... | @@ -522,16 +524,43 @@ enum Notebooks { |
| 522 | return [] | 524 | return [] |
| 523 | } | 525 | } |
| 524 | var added: [Notebook] = [] | 526 | var added: [Notebook] = [] |
| 525 | list = notebooks(in: folder).map { [list] name in | 527 | list = ordered( |
| 526 | if let kept = list.first(where: { $0.id == prefix + name }) { return kept } | 528 | notebooks(in: folder).map { [list] name in |
| 527 | let notebook = Notebook( | 529 | if let kept = list.first(where: { $0.id == prefix + name }) { return kept } |
| 528 | id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name)) | 530 | let notebook = Notebook( |
| 529 | added.append(notebook) | 531 | id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name)) |
| 530 | return notebook | 532 | added.append(notebook) |
| 531 | } | 533 | return notebook |
| 534 | }) | ||
| 532 | return added | 535 | return added |
| 533 | } | 536 | } |
| 534 | 537 | ||
| 538 | /// `list` in the reader's order, those it doesn't name after in their own. | ||
| 539 | private static func ordered(_ list: [Notebook]) -> [Notebook] { | ||
| 540 | let order = UserDefaults.standard.stringArray(forKey: order) ?? [] | ||
| 541 | let rank = { (notebook: Notebook) in order.firstIndex(of: notebook.id) ?? order.count } | ||
| 542 | return list.enumerated().sorted { (rank($0.element), $0.offset) < (rank($1.element), $1.offset) }.map(\.element) | ||
| 543 | } | ||
| 544 | |||
| 545 | /// The notebooks listed in the same place as `notebook`, in order. | ||
| 546 | static func siblings(of notebook: Notebook) -> [Notebook] { | ||
| 547 | [inCloud, onDevice, elsewhere].first { $0.contains { $0 === notebook } } ?? [] | ||
| 548 | } | ||
| 549 | |||
| 550 | /// Puts `notebook` at `index` among the notebooks of its place, and keeps that order. | ||
| 551 | static func move(_ notebook: Notebook, to index: Int) { | ||
| 552 | func move(in list: inout [Notebook]) { | ||
| 553 | guard let at = list.firstIndex(where: { $0 === notebook }) else { return } | ||
| 554 | let moved = list.remove(at: at) | ||
| 555 | list.insert(moved, at: min(max(index, 0), list.count)) | ||
| 556 | } | ||
| 557 | move(in: &inCloud) | ||
| 558 | move(in: &onDevice) | ||
| 559 | move(in: &elsewhere) | ||
| 560 | guard scripted == nil else { return } | ||
| 561 | UserDefaults.standard.set(all.map(\.id), forKey: order) | ||
| 562 | } | ||
| 563 | |||
| 535 | /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as | 564 | /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as |
| 536 | /// when another device added or removed one. | 565 | /// when another device added or removed one. |
| 537 | static var inCloudChanged: Bool { | 566 | static var inCloudChanged: Bool { |
crates/mobile/include/snowbound.h+4| ... | @@ -32,6 +32,9 @@ char *sb_library_sections(const Library *library); | ... | @@ -32,6 +32,9 @@ char *sb_library_sections(const Library *library); |
| 32 | int32_t sb_library_color(const Library *library); | 32 | int32_t sb_library_color(const Library *library); |
| 33 | char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, | 33 | char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, |
| 34 | const char *date, const char *time); | 34 | const char *date, const char *time); |
| 35 | char *sb_library_move_page(const Library *library, const char *from, const char *id, const char *to, const char *before, | ||
| 36 | uint32_t level, const char *discard, const char *author, const char *date, const char *time); | ||
| 37 | char *sb_library_place(const Library *library, const char *path, const char *folder, const char *paths); | ||
| 35 | char *sb_search(const Library *const *libraries, size_t count, const char *section, const char *query); | 38 | char *sb_search(const Library *const *libraries, size_t count, const char *section, const char *query); |
| 36 | char *sb_library_tagged(const Library *library); | 39 | char *sb_library_tagged(const Library *library); |
| 37 | bool sb_library_unlock(const Library *library, const char *path, const char *password); | 40 | bool sb_library_unlock(const Library *library, const char *path, const char *password); |
| ... | @@ -55,6 +58,7 @@ char *sb_section_pages(const Section *section); | ... | @@ -55,6 +58,7 @@ char *sb_section_pages(const Section *section); |
| 55 | uint32_t sb_section_poll(const Section *section, uint8_t *status); | 58 | uint32_t sb_section_poll(const Section *section, uint8_t *status); |
| 56 | char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time); | 59 | char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time); |
| 57 | bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time); | 60 | bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time); |
| 61 | bool sb_section_arrange(const Section *section, const char *order, const char *moved); | ||
| 58 | char *sb_section_themes(const Section *section, const char *id); | 62 | char *sb_section_themes(const Section *section, const char *id); |
| 59 | bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); | 63 | bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); |
| 60 | bool sb_section_flush(const Section *section, double seconds); | 64 | bool sb_section_flush(const Section *section, double seconds); |
crates/mobile/src/library.rs+239| ... | @@ -931,6 +931,20 @@ impl Section { | ... | @@ -931,6 +931,20 @@ impl Section { |
| 931 | }) | 931 | }) |
| 932 | } | 932 | } |
| 933 | 933 | ||
| 934 | /// Puts the pages `moved` where `order` lists them, at their levels, as one edit; false | ||
| 935 | /// where none of them is listed any more. | ||
| 936 | pub(crate) fn arrange(&self, order: &[(ExGuid, u32)], moved: &[ExGuid]) -> Result<bool> { | ||
| 937 | let edits = session::arrange(&self.shared.section.pages()?, order, moved)?; | ||
| 938 | if edits.is_empty() { | ||
| 939 | return Ok(false); | ||
| 940 | } | ||
| 941 | self.shared.apply(Edit { | ||
| 942 | at: filetime(), | ||
| 943 | ops: vec![Op::Section(SectionOp::Pages(edits))], | ||
| 944 | })?; | ||
| 945 | Ok(true) | ||
| 946 | } | ||
| 947 | |||
| 934 | /// Applies the events since the last poll to the shared status. | 948 | /// Applies the events since the last poll to the shared status. |
| 935 | pub(crate) fn poll(&self) -> u32 { | 949 | pub(crate) fn poll(&self) -> u32 { |
| 936 | let mut flags = 0; | 950 | let mut flags = 0; |
| ... | @@ -1205,6 +1219,130 @@ impl Library { | ... | @@ -1205,6 +1219,130 @@ impl Library { |
| 1205 | Ok(()) | 1219 | Ok(()) |
| 1206 | } | 1220 | } |
| 1207 | 1221 | ||
| 1222 | /// Runs `change` on the section at catalog `path`: through its session where one is open, | ||
| 1223 | /// else through one opened for it and closed after, its edits left to the background. | ||
| 1224 | fn with_section<T>( | ||
| 1225 | &self, | ||
| 1226 | path: &str, | ||
| 1227 | change: impl FnOnce(&session::Section) -> Result<T>, | ||
| 1228 | ) -> Result<T> { | ||
| 1229 | if let Some((_, shared)) = self | ||
| 1230 | .open_sections() | ||
| 1231 | .into_iter() | ||
| 1232 | .find(|(open, _)| open == path) | ||
| 1233 | { | ||
| 1234 | return change(&shared.section); | ||
| 1235 | } | ||
| 1236 | let section = self.section(path, || {})?; | ||
| 1237 | let result = change(&section); | ||
| 1238 | section.close()?; | ||
| 1239 | result | ||
| 1240 | } | ||
| 1241 | |||
| 1242 | /// Moves page `space` of the section at catalog `from` into the section at `to`, before | ||
| 1243 | /// `before` at `level` (last without), as OneNote moves a page dropped on a section's tab: | ||
| 1244 | /// it keeps its identity, title, date and content there. A section left without pages | ||
| 1245 | /// gains one titled `date` and `time`; `discard`, the page `to` gained when this page | ||
| 1246 | /// left it, goes while it holds nothing, as Undo takes the move back. | ||
| 1247 | #[allow(clippy::too_many_arguments)] | ||
| 1248 | pub(crate) fn move_page( | ||
| 1249 | &self, | ||
| 1250 | from: &str, | ||
| 1251 | space: ExGuid, | ||
| 1252 | to: &str, | ||
| 1253 | before: Option<ExGuid>, | ||
| 1254 | level: u32, | ||
| 1255 | discard: Option<ExGuid>, | ||
| 1256 | author: &str, | ||
| 1257 | [date, time]: [&str; 2], | ||
| 1258 | ) -> Result<MovedPage> { | ||
| 1259 | if from == to { | ||
| 1260 | return Err("The page is in that section already".into()); | ||
| 1261 | } | ||
| 1262 | let (page, was) = self.with_section(from, |section| { | ||
| 1263 | let listed = section.pages()?; | ||
| 1264 | let at = (listed.iter().position(|(listed, ..)| *listed == space)) | ||
| 1265 | .ok_or("The page is not listed")?; | ||
| 1266 | let next = listed.get(at + 1).map(|(next, ..)| *next); | ||
| 1267 | Ok((section.page(space)?, (next, listed[at].2))) | ||
| 1268 | })?; | ||
| 1269 | let import = session::moved(&page, author)?; | ||
| 1270 | let Op::Section(SectionOp::Import { creation, .. }) = &import else { | ||
| 1271 | unreachable!("notebook::session::moved imports"); | ||
| 1272 | }; | ||
| 1273 | let arrived = creation.space(); | ||
| 1274 | self.with_section(to, |section| { | ||
| 1275 | let listed = section.pages()?; | ||
| 1276 | let has = |page: &ExGuid| listed.iter().any(|(space, ..)| space == page); | ||
| 1277 | let mut ops = vec![import]; | ||
| 1278 | let before = before.filter(has); | ||
| 1279 | if before.is_some() || level != 1 { | ||
| 1280 | ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to( | ||
| 1281 | arrived, before, level, | ||
| 1282 | )?]))); | ||
| 1283 | } | ||
| 1284 | if let Some(discard) = discard.filter(has) | ||
| 1285 | && session::blank(&section.page(discard)?) | ||
| 1286 | { | ||
| 1287 | ops.push(Op::Section(SectionOp::Delete(vec![discard]))); | ||
| 1288 | } | ||
| 1289 | Ok(section.apply( | ||
| 1290 | author, | ||
| 1291 | Edit { | ||
| 1292 | at: filetime(), | ||
| 1293 | ops, | ||
| 1294 | }, | ||
| 1295 | )?) | ||
| 1296 | })?; | ||
| 1297 | let added = self.with_section(from, |section| { | ||
| 1298 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; | ||
| 1299 | let added = match section.pages()?.as_slice() { | ||
| 1300 | [(only, ..)] if *only == space => { | ||
| 1301 | let fresh = dated(author, date, time)?; | ||
| 1302 | let added = fresh.space(); | ||
| 1303 | ops.push(Op::Section(SectionOp::Create(fresh))); | ||
| 1304 | Some(added) | ||
| 1305 | } | ||
| 1306 | _ => None, | ||
| 1307 | }; | ||
| 1308 | section.apply( | ||
| 1309 | author, | ||
| 1310 | Edit { | ||
| 1311 | at: filetime(), | ||
| 1312 | ops, | ||
| 1313 | }, | ||
| 1314 | )?; | ||
| 1315 | Ok(added) | ||
| 1316 | })?; | ||
| 1317 | let id = |space: Option<ExGuid>| space.map(|space| space.to_string()); | ||
| 1318 | Ok(MovedPage { | ||
| 1319 | page: arrived.to_string(), | ||
| 1320 | added: id(added), | ||
| 1321 | before: id(was.0), | ||
| 1322 | level: was.1, | ||
| 1323 | }) | ||
| 1324 | } | ||
| 1325 | |||
| 1326 | /// Puts the section or group at catalog `path` into the group `folder` ("" for the | ||
| 1327 | /// notebook's top), moving it there unless it is there, as OneNote moves one dragged | ||
| 1328 | /// onto a group, then orders `folder` as `paths` lists its entries, as they are named | ||
| 1329 | /// once moved; entries left out follow in their order. Returns its catalog path. | ||
| 1330 | pub(crate) fn place(&self, path: &str, folder: &str, paths: &[String]) -> Result<String> { | ||
| 1331 | let parent = path.rsplit_once('/').map_or("", |(parent, _)| parent); | ||
| 1332 | self.with_notebook(true, |notebook| { | ||
| 1333 | notebook.refresh()?; | ||
| 1334 | let placed = match parent == folder { | ||
| 1335 | true => path.to_owned(), | ||
| 1336 | false => notebook.move_entry(path, folder)?, | ||
| 1337 | }; | ||
| 1338 | if !paths.is_empty() { | ||
| 1339 | let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); | ||
| 1340 | notebook.reorder(folder, &paths)?; | ||
| 1341 | } | ||
| 1342 | Ok(placed) | ||
| 1343 | }) | ||
| 1344 | } | ||
| 1345 | |||
| 1208 | fn open_sections(&self) -> Vec<(String, Arc<Shared>)> { | 1346 | fn open_sections(&self) -> Vec<(String, Arc<Shared>)> { |
| 1209 | let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner()); | 1347 | let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner()); |
| 1210 | open.retain(|(_, shared)| shared.strong_count() > 0); | 1348 | open.retain(|(_, shared)| shared.strong_count() > 0); |
| ... | @@ -1214,6 +1352,16 @@ impl Library { | ... | @@ -1214,6 +1352,16 @@ impl Library { |
| 1214 | } | 1352 | } |
| 1215 | } | 1353 | } |
| 1216 | 1354 | ||
| 1355 | /// A page `Library::move_page` moved, by id: where it went, the page the section it left | ||
| 1356 | /// gained, and where it was there: before `before`, at `level`. | ||
| 1357 | #[derive(serde::Serialize)] | ||
| 1358 | pub(crate) struct MovedPage { | ||
| 1359 | pub(crate) page: String, | ||
| 1360 | pub(crate) added: Option<String>, | ||
| 1361 | pub(crate) before: Option<String>, | ||
| 1362 | pub(crate) level: u32, | ||
| 1363 | } | ||
| 1364 | |||
| 1217 | #[derive(serde::Serialize)] | 1365 | #[derive(serde::Serialize)] |
| 1218 | pub(crate) struct Synced { | 1366 | pub(crate) struct Synced { |
| 1219 | pub(crate) path: String, | 1367 | pub(crate) path: String, |
| ... | @@ -1477,6 +1625,63 @@ pub unsafe extern "C" fn sb_library_new_section( | ... | @@ -1477,6 +1625,63 @@ pub unsafe extern "C" fn sb_library_new_section( |
| 1477 | .map_or(std::ptr::null_mut(), owned) | 1625 | .map_or(std::ptr::null_mut(), owned) |
| 1478 | } | 1626 | } |
| 1479 | 1627 | ||
| 1628 | /// Moves page `id` of the section at catalog `from` into the section at `to` (`Library::move_page`), | ||
| 1629 | /// before page `before` at `level`, or last with `before` null; `discard`, unless null, is | ||
| 1630 | /// the page `to` gained when this page left it. Returns JSON with `page`, its id there, | ||
| 1631 | /// `added`, the page titled `date` and `time` that `from` gained, or null, and `before` and | ||
| 1632 | /// `level`, where it was in `from`; null when it cannot move. Writes to both sections, so | ||
| 1633 | /// call it off the main thread. | ||
| 1634 | /// | ||
| 1635 | /// # Safety | ||
| 1636 | /// Every string is NUL-terminated UTF-8; `before` and `discard` may be null. | ||
| 1637 | #[unsafe(no_mangle)] | ||
| 1638 | pub unsafe extern "C" fn sb_library_move_page( | ||
| 1639 | library: &Library, | ||
| 1640 | from: *const c_char, | ||
| 1641 | id: *const c_char, | ||
| 1642 | to: *const c_char, | ||
| 1643 | before: *const c_char, | ||
| 1644 | level: u32, | ||
| 1645 | discard: *const c_char, | ||
| 1646 | author: *const c_char, | ||
| 1647 | date: *const c_char, | ||
| 1648 | time: *const c_char, | ||
| 1649 | ) -> *mut c_char { | ||
| 1650 | let space = |text: *const c_char| optional(text).map(|id| id.parse()).transpose(); | ||
| 1651 | json((|| { | ||
| 1652 | library.move_page( | ||
| 1653 | &string(from), | ||
| 1654 | string(id).parse()?, | ||
| 1655 | &string(to), | ||
| 1656 | space(before)?, | ||
| 1657 | level, | ||
| 1658 | space(discard)?, | ||
| 1659 | &string(author), | ||
| 1660 | [&string(date), &string(time)], | ||
| 1661 | ) | ||
| 1662 | })()) | ||
| 1663 | } | ||
| 1664 | |||
| 1665 | /// Puts the section or group at catalog `path` into the group at catalog path `folder` ("" | ||
| 1666 | /// for the notebook's top), moving it unless it is there, then orders `folder` as `paths`, a | ||
| 1667 | /// JSON array of catalog paths as they are named after the move; entries left out follow | ||
| 1668 | /// in their order. Returns its catalog path, or null. | ||
| 1669 | /// | ||
| 1670 | /// # Safety | ||
| 1671 | /// Every string is NUL-terminated UTF-8. | ||
| 1672 | #[unsafe(no_mangle)] | ||
| 1673 | pub unsafe extern "C" fn sb_library_place( | ||
| 1674 | library: &Library, | ||
| 1675 | path: *const c_char, | ||
| 1676 | folder: *const c_char, | ||
| 1677 | paths: *const c_char, | ||
| 1678 | ) -> *mut c_char { | ||
| 1679 | let placed = serde_json::from_str::<Vec<String>>(&string(paths)) | ||
| 1680 | .map_err(Into::into) | ||
| 1681 | .and_then(|paths| library.place(&string(path), &string(folder), &paths)); | ||
| 1682 | report(placed).map_or(std::ptr::null_mut(), owned) | ||
| 1683 | } | ||
| 1684 | |||
| 1480 | /// Pages of the `count` notebooks at `libraries` whose title or text holds `query`, title | 1685 | /// Pages of the `count` notebooks at `libraries` whose title or text holds `query`, title |
| 1481 | /// matches first and then the most recently changed, as JSON: each with `notebook` (its | 1686 | /// matches first and then the most recently changed, as JSON: each with `notebook` (its |
| 1482 | /// place in `libraries`), `section` (a catalog path), `page`, `title`, `inTitle`, | 1687 | /// place in `libraries`), `section` (a catalog path), `page`, `title`, `inTitle`, |
| ... | @@ -1858,6 +2063,40 @@ pub unsafe extern "C" fn sb_section_delete_page( | ... | @@ -1858,6 +2063,40 @@ pub unsafe extern "C" fn sb_section_delete_page( |
| 1858 | report(deleted).is_some() | 2063 | report(deleted).is_some() |
| 1859 | } | 2064 | } |
| 1860 | 2065 | ||
| 2066 | /// Puts the pages `moved`, a JSON array of ids, where `order`, a JSON array of `{id, level}` | ||
| 2067 | /// listing the section's pages, places them, at their levels, as one edit: a page dragged | ||
| 2068 | /// to a new place, made a subpage or promoted, or put back as Undo does. False when none of | ||
| 2069 | /// them is listed any more. | ||
| 2070 | /// | ||
| 2071 | /// # Safety | ||
| 2072 | /// `order` and `moved` are NUL-terminated UTF-8. | ||
| 2073 | #[unsafe(no_mangle)] | ||
| 2074 | pub unsafe extern "C" fn sb_section_arrange( | ||
| 2075 | section: &Section, | ||
| 2076 | order: *const c_char, | ||
| 2077 | moved: *const c_char, | ||
| 2078 | ) -> bool { | ||
| 2079 | #[derive(serde::Deserialize)] | ||
| 2080 | struct Placed { | ||
| 2081 | id: String, | ||
| 2082 | level: u32, | ||
| 2083 | } | ||
| 2084 | let arranged = (|| { | ||
| 2085 | let order: Vec<Placed> = serde_json::from_str(&string(order))?; | ||
| 2086 | let order = order | ||
| 2087 | .into_iter() | ||
| 2088 | .map(|placed| Ok((placed.id.parse()?, placed.level))) | ||
| 2089 | .collect::<Result<Vec<_>>>()?; | ||
| 2090 | let moved: Vec<String> = serde_json::from_str(&string(moved))?; | ||
| 2091 | let moved = moved | ||
| 2092 | .iter() | ||
| 2093 | .map(|id| Ok(id.parse()?)) | ||
| 2094 | .collect::<Result<Vec<_>>>()?; | ||
| 2095 | section.arrange(&order, &moved) | ||
| 2096 | })(); | ||
| 2097 | report(arranged).unwrap_or(false) | ||
| 2098 | } | ||
| 2099 | |||
| 1861 | /// Waits for every edit to be stored in the cache, then up to `seconds` for them to be | 2100 | /// Waits for every edit to be stored in the cache, then up to `seconds` for them to be |
| 1862 | /// published; true when nothing waits. For the host's background time. | 2101 | /// published; true when nothing waits. For the host's background time. |
| 1863 | #[unsafe(no_mangle)] | 2102 | #[unsafe(no_mangle)] |
crates/mobile/src/tests.rs+181| ... | @@ -1274,3 +1274,184 @@ fn a_movie_from_the_camera_becomes_the_desktop_s_avi() { | ... | @@ -1274,3 +1274,184 @@ fn a_movie_from_the_camera_becomes_the_desktop_s_avi() { |
| 1274 | assert_eq!((parsed.frames.len(), parsed.duration_ms()), (15, 1000)); | 1274 | assert_eq!((parsed.frames.len(), parsed.duration_ms()), (15, 1000)); |
| 1275 | assert!(parsed.sound.is_some()); | 1275 | assert!(parsed.sound.is_some()); |
| 1276 | } | 1276 | } |
| 1277 | |||
| 1278 | fn levels(section: &Section) -> Vec<(ExGuid, u32)> { | ||
| 1279 | let pages = section.shared.section.pages().unwrap(); | ||
| 1280 | pages | ||
| 1281 | .into_iter() | ||
| 1282 | .map(|(space, _, level)| (space, level)) | ||
| 1283 | .collect() | ||
| 1284 | } | ||
| 1285 | |||
| 1286 | #[test] | ||
| 1287 | fn pages_drag_indent_and_undo_as_one_edit_each() { | ||
| 1288 | let (_directory, _root, _library, section) = notebook_open("Features.one"); | ||
| 1289 | let before = levels(&section); | ||
| 1290 | assert!(before.len() > 2); | ||
| 1291 | let last = before.last().unwrap().0; | ||
| 1292 | let mut dragged = before.clone(); | ||
| 1293 | let moved = dragged.pop().unwrap(); | ||
| 1294 | dragged.insert(0, moved); | ||
| 1295 | let revisions = section.shared.section.pending().unwrap().len(); | ||
| 1296 | assert!(section.arrange(&dragged, &[last]).unwrap()); | ||
| 1297 | assert_eq!(levels(&section), dragged); | ||
| 1298 | assert_eq!( | ||
| 1299 | section.shared.section.pending().unwrap().len(), | ||
| 1300 | revisions + 1 | ||
| 1301 | ); | ||
| 1302 | let second = dragged[1].0; | ||
| 1303 | let mut indented = dragged.clone(); | ||
| 1304 | indented[1].1 = 2; | ||
| 1305 | assert!(section.arrange(&indented, &[second]).unwrap()); | ||
| 1306 | assert_eq!(levels(&section), indented); | ||
| 1307 | assert!(section.arrange(&dragged, &[second]).unwrap()); | ||
| 1308 | assert!(section.arrange(&before, &[last]).unwrap()); | ||
| 1309 | assert_eq!(levels(&section), before); | ||
| 1310 | assert!(!section.arrange(&before, &[ExGuid::default()]).unwrap()); | ||
| 1311 | } | ||
| 1312 | |||
| 1313 | #[test] | ||
| 1314 | fn a_page_moves_to_another_section_and_back_where_it_was() { | ||
| 1315 | let (_directory, root, library, features) = notebook_open("Features.one"); | ||
| 1316 | let before = levels(&features); | ||
| 1317 | let (space, level) = before[1]; | ||
| 1318 | let title = features.shared.section.page(space).unwrap().title; | ||
| 1319 | let date = ["Thursday, October 1, 2026", "9:00 AM"]; | ||
| 1320 | let moved = library | ||
| 1321 | .move_page( | ||
| 1322 | "Features.one", | ||
| 1323 | space, | ||
| 1324 | "Empty.one", | ||
| 1325 | None, | ||
| 1326 | 1, | ||
| 1327 | None, | ||
| 1328 | "Clover Test", | ||
| 1329 | date, | ||
| 1330 | ) | ||
| 1331 | .unwrap(); | ||
| 1332 | let arrived: ExGuid = moved.page.parse().unwrap(); | ||
| 1333 | assert_eq!(moved.added, None); | ||
| 1334 | assert_eq!(moved.before, Some(before[2].0.to_string())); | ||
| 1335 | assert_eq!(moved.level, level); | ||
| 1336 | assert!(levels(&features).iter().all(|(page, _)| *page != space)); | ||
| 1337 | let empty = open(&library, "Empty.one"); | ||
| 1338 | let listed = empty.shared.section.pages().unwrap(); | ||
| 1339 | assert_eq!(listed.last().unwrap(), &(arrived, title.clone(), 1)); | ||
| 1340 | drop(empty); | ||
| 1341 | let back = library | ||
| 1342 | .move_page( | ||
| 1343 | "Empty.one", | ||
| 1344 | arrived, | ||
| 1345 | "Features.one", | ||
| 1346 | moved.before.map(|before| before.parse().unwrap()), | ||
| 1347 | moved.level, | ||
| 1348 | None, | ||
| 1349 | "Clover Test", | ||
| 1350 | date, | ||
| 1351 | ) | ||
| 1352 | .unwrap() | ||
| 1353 | .page | ||
| 1354 | .parse() | ||
| 1355 | .unwrap(); | ||
| 1356 | let mut returned = before.clone(); | ||
| 1357 | returned[1].0 = back; | ||
| 1358 | assert_eq!(levels(&features), returned); | ||
| 1359 | assert_eq!(features.shared.section.page(back).unwrap().title, title); | ||
| 1360 | drop(features); | ||
| 1361 | library.close(Duration::from_secs(20)).unwrap(); | ||
| 1362 | let arena = onestore::Arena::default(); | ||
| 1363 | let file = onestore::read_file(root.join("Features.one")).unwrap(); | ||
| 1364 | let stored = onestore::Section::open(&arena, file).unwrap(); | ||
| 1365 | assert_eq!(stored.page(back).unwrap().title, title); | ||
| 1366 | } | ||
| 1367 | |||
| 1368 | #[test] | ||
| 1369 | fn a_sections_last_page_moving_leaves_a_page_that_undo_takes_out() { | ||
| 1370 | let (_directory, _root, library, features) = notebook_open("Features.one"); | ||
| 1371 | let date = ["Thursday, October 1, 2026", "9:02 AM"]; | ||
| 1372 | let page = features.new_page(None, date[0], date[1]).unwrap(); | ||
| 1373 | let arrived: ExGuid = library | ||
| 1374 | .move_page( | ||
| 1375 | "Features.one", | ||
| 1376 | page, | ||
| 1377 | "Empty.one", | ||
| 1378 | None, | ||
| 1379 | 1, | ||
| 1380 | None, | ||
| 1381 | "Clover Test", | ||
| 1382 | date, | ||
| 1383 | ) | ||
| 1384 | .unwrap() | ||
| 1385 | .page | ||
| 1386 | .parse() | ||
| 1387 | .unwrap(); | ||
| 1388 | assert_eq!(levels(&open(&library, "Empty.one")), [(arrived, 1)]); | ||
| 1389 | let returned = library | ||
| 1390 | .move_page( | ||
| 1391 | "Empty.one", | ||
| 1392 | arrived, | ||
| 1393 | "Features.one", | ||
| 1394 | None, | ||
| 1395 | 1, | ||
| 1396 | None, | ||
| 1397 | "Clover Test", | ||
| 1398 | date, | ||
| 1399 | ) | ||
| 1400 | .unwrap(); | ||
| 1401 | let back = returned.page.parse().unwrap(); | ||
| 1402 | let added: ExGuid = returned.added.unwrap().parse().unwrap(); | ||
| 1403 | assert_eq!(levels(&open(&library, "Empty.one")), [(added, 1)]); | ||
| 1404 | library | ||
| 1405 | .move_page( | ||
| 1406 | "Features.one", | ||
| 1407 | back, | ||
| 1408 | "Empty.one", | ||
| 1409 | None, | ||
| 1410 | 1, | ||
| 1411 | Some(added), | ||
| 1412 | "Clover Test", | ||
| 1413 | date, | ||
| 1414 | ) | ||
| 1415 | .unwrap(); | ||
| 1416 | assert_eq!(levels(&open(&library, "Empty.one")).len(), 1); | ||
| 1417 | } | ||
| 1418 | |||
| 1419 | #[test] | ||
| 1420 | fn sections_reorder_and_move_between_groups_and_back() { | ||
| 1421 | let (_directory, root, library, features) = notebook_open("Features.one"); | ||
| 1422 | drop(features); | ||
| 1423 | let top = |library: &Library| -> Vec<String> { | ||
| 1424 | let tabs = library.tabs().unwrap(); | ||
| 1425 | tabs.into_iter() | ||
| 1426 | .filter(|tab| tab.group.is_empty()) | ||
| 1427 | .map(|tab| tab.path) | ||
| 1428 | .collect() | ||
| 1429 | }; | ||
| 1430 | let before = top(&library); | ||
| 1431 | let mut reversed = before.clone(); | ||
| 1432 | reversed.reverse(); | ||
| 1433 | assert_eq!( | ||
| 1434 | library.place("Empty.one", "", &reversed).unwrap(), | ||
| 1435 | "Empty.one" | ||
| 1436 | ); | ||
| 1437 | assert_eq!(top(&library), reversed); | ||
| 1438 | assert_eq!( | ||
| 1439 | library.place("Empty.one", "Group A", &[]).unwrap(), | ||
| 1440 | "Group A/Empty.one" | ||
| 1441 | ); | ||
| 1442 | assert!(root.join("Group A/Empty.one").exists()); | ||
| 1443 | let tabs = library.tabs().unwrap(); | ||
| 1444 | let grouped: Vec<&str> = tabs | ||
| 1445 | .iter() | ||
| 1446 | .filter(|tab| tab.group == "Group A") | ||
| 1447 | .map(|tab| tab.path.as_str()) | ||
| 1448 | .collect(); | ||
| 1449 | assert_eq!(grouped.last(), Some(&"Group A/Empty.one")); | ||
| 1450 | assert_eq!( | ||
| 1451 | library.place("Group A/Empty.one", "", &before).unwrap(), | ||
| 1452 | "Empty.one" | ||
| 1453 | ); | ||
| 1454 | assert_eq!(top(&library), before); | ||
| 1455 | assert!(root.join("Empty.one").exists()); | ||
| 1456 | open(&library, "Empty.one"); | ||
| 1457 | } |
crates/notebook/src/session.rs+34-1| ... | @@ -6,7 +6,7 @@ use crate::{ | ... | @@ -6,7 +6,7 @@ use crate::{ |
| 6 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs, | 6 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs, |
| 7 | }; | 7 | }; |
| 8 | use onestore::{ | 8 | use onestore::{ |
| 9 | CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction, | 9 | CommitError, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store, Transaction, |
| 10 | document::Document, | 10 | document::Document, |
| 11 | op::{Edit, Op, SectionOp}, | 11 | op::{Edit, Op, SectionOp}, |
| 12 | page::Page, | 12 | page::Page, |
| ... | @@ -1724,6 +1724,39 @@ pub fn moved(page: &Page, author: &str) -> Result<Op> { | ... | @@ -1724,6 +1724,39 @@ pub fn moved(page: &Page, author: &str) -> Result<Op> { |
| 1724 | })) | 1724 | })) |
| 1725 | } | 1725 | } |
| 1726 | 1726 | ||
| 1727 | /// The edits putting the pages `moved` where `order` lists them, with their levels, in a | ||
| 1728 | /// section now listing `listed`: each goes before the next page of `order` that stays put | ||
| 1729 | /// and is still listed. Pages gone from the section are left out. | ||
| 1730 | pub fn arrange( | ||
| 1731 | listed: &[(ExGuid, String, u32)], | ||
| 1732 | order: &[(ExGuid, u32)], | ||
| 1733 | moved: &[ExGuid], | ||
| 1734 | ) -> Result<Vec<PageEdit>> { | ||
| 1735 | let present = |space: &ExGuid| listed.iter().any(|(listed, ..)| listed == space); | ||
| 1736 | let mut edits = Vec::new(); | ||
| 1737 | for (index, (space, level)) in order.iter().enumerate() { | ||
| 1738 | if !moved.contains(space) || !present(space) { | ||
| 1739 | continue; | ||
| 1740 | } | ||
| 1741 | let before = order[index + 1..] | ||
| 1742 | .iter() | ||
| 1743 | .map(|(space, _)| *space) | ||
| 1744 | .find(|space| !moved.contains(space) && present(space)); | ||
| 1745 | edits.push(PageEdit::move_to(*space, before, *level)?); | ||
| 1746 | } | ||
| 1747 | Ok(edits) | ||
| 1748 | } | ||
| 1749 | |||
| 1750 | /// Whether `page` holds nothing but an empty title, as the page a section left without | ||
| 1751 | /// pages gains does. | ||
| 1752 | pub fn blank(page: &Page) -> bool { | ||
| 1753 | page.title.trim().is_empty() | ||
| 1754 | && page | ||
| 1755 | .objects | ||
| 1756 | .iter() | ||
| 1757 | .all(|object| matches!(object, onestore::page::PageObject::Title(_))) | ||
| 1758 | } | ||
| 1759 | |||
| 1727 | fn component(name: &str) -> bool { | 1760 | fn component(name: &str) -> bool { |
| 1728 | !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".." | 1761 | !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".." |
| 1729 | } | 1762 | } |
crates/snowbound/src/print.rs+1-1| ... | @@ -446,7 +446,7 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> { | ... | @@ -446,7 +446,7 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> { |
| 446 | fn printed(pages: Vec<Page>) -> Vec<Page> { | 446 | fn printed(pages: Vec<Page>) -> Vec<Page> { |
| 447 | pages | 447 | pages |
| 448 | .into_iter() | 448 | .into_iter() |
| 449 | .filter(|page| !crate::undo::blank(page)) | 449 | .filter(|page| !notebook::session::blank(page)) |
| 450 | .collect() | 450 | .collect() |
| 451 | } | 451 | } |
| 452 | 452 |
crates/snowbound/src/undo.rs+7-23| ... | @@ -6,11 +6,15 @@ | ... | @@ -6,11 +6,15 @@ |
| 6 | use crate::library::{entry_name, folders}; | 6 | use crate::library::{entry_name, folders}; |
| 7 | use crate::{Command, Library, Loaded, State, UserEvent}; | 7 | use crate::{Command, Library, Loaded, State, UserEvent}; |
| 8 | use canvas::editor::CanvasEditor; | 8 | use canvas::editor::CanvasEditor; |
| 9 | use notebook::{Replica, discover::Folder}; | 9 | use notebook::{ |
| 10 | Replica, | ||
| 11 | discover::Folder, | ||
| 12 | session::{arrange, blank}, | ||
| 13 | }; | ||
| 10 | use onestore::{ | 14 | use onestore::{ |
| 11 | ExGuid, PageCreation, PageEdit, | 15 | ExGuid, PageCreation, PageEdit, |
| 12 | op::{Edit, Op, SectionOp}, | 16 | op::{Edit, Op, SectionOp}, |
| 13 | page::{Page, PageObject}, | 17 | page::Page, |
| 14 | }; | 18 | }; |
| 15 | use std::{error::Error, sync::Arc}; | 19 | use std::{error::Error, sync::Arc}; |
| 16 | 20 | ||
| ... | @@ -285,15 +289,6 @@ fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) { | ... | @@ -285,15 +289,6 @@ fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) { |
| 285 | } | 289 | } |
| 286 | } | 290 | } |
| 287 | 291 | ||
| 288 | /// Whether `page` holds nothing but its title, untitled. | ||
| 289 | pub(crate) fn blank(page: &Page) -> bool { | ||
| 290 | page.title.trim().is_empty() | ||
| 291 | && page | ||
| 292 | .objects | ||
| 293 | .iter() | ||
| 294 | .all(|object| matches!(object, PageObject::Title(_))) | ||
| 295 | } | ||
| 296 | |||
| 297 | /// The page shown once `gone` leave the section listing `listed`: `prefer` if it stays, | 292 | /// The page shown once `gone` leave the section listing `listed`: `prefer` if it stays, |
| 298 | /// else the page after the first one gone, or before it, as OneNote 2010 shows. | 293 | /// else the page after the first one gone, or before it, as OneNote 2010 shows. |
| 299 | fn neighbor( | 294 | fn neighbor( |
| ... | @@ -502,18 +497,7 @@ impl Site { | ... | @@ -502,18 +497,7 @@ impl Site { |
| 502 | } | 497 | } |
| 503 | } | 498 | } |
| 504 | Change::Arrange { order, moved } => { | 499 | Change::Arrange { order, moved } => { |
| 505 | let moving = |space: &ExGuid| moved.contains(space) && at(*space).is_some(); | 500 | let edits = arrange(&listed, &order, &moved)?; |
| 506 | let mut edits = Vec::new(); | ||
| 507 | for (index, (space, level)) in order.iter().enumerate() { | ||
| 508 | if !moving(space) { | ||
| 509 | continue; | ||
| 510 | } | ||
| 511 | let before = order[index + 1..] | ||
| 512 | .iter() | ||
| 513 | .map(|(space, _)| *space) | ||
| 514 | .find(|space| !moved.contains(space) && at(*space).is_some()); | ||
| 515 | edits.push(PageEdit::move_to(*space, before, *level)?); | ||
| 516 | } | ||
| 517 | let Some(show) = edits.first().map(PageEdit::space) else { | 501 | let Some(show) = edits.first().map(PageEdit::space) else { |
| 518 | return Ok(None); | 502 | return Ok(None); |
| 519 | }; | 503 | }; |