| ... | ... | @@ -47,7 +47,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 47 | 47 | var list = UICollectionLayoutListConfiguration(appearance: compact ? .insetGrouped : .sidebar) |
| 48 | 48 | list.headerMode = .firstItemInSection |
| 49 | 49 | list.trailingSwipeActionsConfigurationProvider = { indexPath in |
| 50 | | guard let self, let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil } |
| 50 | guard let self, !self.isEditing, let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil } |
| 51 | 51 | if case .notebook(let id) = item, let notebook = self.notebook(id) { |
| 52 | 52 | return UISwipeActionsConfiguration(actions: self.swipeActions(for: notebook, at: indexPath)) |
| 53 | 53 | } |
| ... | ... | @@ -141,14 +141,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 141 | 141 | ] |
| 142 | 142 | navigationItem.searchController = SearchViewController.controller() |
| 143 | 143 | setUpArranging() |
| 144 | | let corner = Prototype.quickNote ? [QuickNote.item()] : [] |
| 145 | 144 | if #available(iOS 26, *) { |
| 146 | 145 | // Search sits in the bottom toolbar, as Notes and Files put it. |
| 147 | 146 | navigationItem.preferredSearchBarPlacement = .integrated |
| 148 | | toolbarItems = [navigationItem.searchBarPlacementBarButtonItem] + (corner.isEmpty ? [] : [.fixedSpace()] + corner) |
| 149 | | } else { |
| 150 | | toolbarItems = corner.isEmpty ? [] : [.flexibleSpace()] + corner |
| 151 | 147 | } |
| 148 | updateSelectionActions() |
| 152 | 149 | for name in [Sync.changed, Section.changed] { |
| 153 | 150 | NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil) |
| 154 | 151 | } |
| ... | ... | @@ -172,6 +169,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 172 | 169 | |
| 173 | 170 | func reload(animated: Bool = false) { |
| 174 | 171 | guard isViewLoaded else { return } |
| 172 | let selection = collectionView.indexPathsForSelectedItems?.compactMap { dataSource.itemIdentifier(for: $0) } ?? [] |
| 175 | 173 | if Prototype.welcome { |
| 176 | 174 | let welcome = Prototype.welcome( |
| 177 | 175 | new: { [weak self] in self?.newNotebook(inCloud: ICloud.documents != nil) }, |
| ... | ... | @@ -230,6 +228,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 230 | 228 | dataSource.apply(list, to: location, animatingDifferences: animated) |
| 231 | 229 | } |
| 232 | 230 | showSelection() |
| 231 | if isEditing { |
| 232 | for item in selection { |
| 233 | if let index = dataSource.indexPath(for: item) { |
| 234 | collectionView.selectItem(at: index, animated: false, scrollPosition: []) |
| 235 | } |
| 236 | } |
| 237 | updateSelectionActions() |
| 238 | } |
| 233 | 239 | refreshSync() |
| 234 | 240 | } |
| 235 | 241 | |
| ... | ... | @@ -294,10 +300,142 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 294 | 300 | |
| 295 | 301 | /// Marks the open section, as the sidebar of a wide window keeps it. |
| 296 | 302 | private func showSelection() { |
| 297 | | guard let selected, let indexPath = dataSource.indexPath(for: selected) else { return } |
| 303 | guard !isEditing, let selected, let indexPath = dataSource.indexPath(for: selected) else { return } |
| 298 | 304 | collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) |
| 299 | 305 | } |
| 300 | 306 | |
| 307 | override func setEditing(_ editing: Bool, animated: Bool) { |
| 308 | super.setEditing(editing, animated: animated) |
| 309 | for index in collectionView.indexPathsForSelectedItems ?? [] { |
| 310 | collectionView.deselectItem(at: index, animated: false) |
| 311 | } |
| 312 | collectionView.isEditing = editing |
| 313 | collectionView.dragInteractionEnabled = !editing |
| 314 | if !editing { showSelection() } |
| 315 | updateSelectionActions() |
| 316 | } |
| 317 | |
| 318 | private func updateSelectionActions() { |
| 319 | guard isEditing else { |
| 320 | let corner = Prototype.quickNote ? [QuickNote.item()] : [] |
| 321 | if #available(iOS 26, *) { |
| 322 | toolbarItems = [navigationItem.searchBarPlacementBarButtonItem] + (corner.isEmpty ? [] : [.fixedSpace()] + corner) |
| 323 | } else { |
| 324 | toolbarItems = corner.isEmpty ? [] : [.flexibleSpace()] + corner |
| 325 | } |
| 326 | navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false) |
| 327 | return |
| 328 | } |
| 329 | let selection = (collectionView.indexPathsForSelectedItems ?? []).sorted().compactMap { |
| 330 | dataSource.itemIdentifier(for: $0) |
| 331 | } |
| 332 | var actions: [UIMenuElement] = [] |
| 333 | let notebooks = selection.compactMap { item -> Notebook? in |
| 334 | guard case .notebook(let id) = item else { return nil } |
| 335 | return notebook(id) |
| 336 | } |
| 337 | if !selection.isEmpty, notebooks.count == selection.count { |
| 338 | let folders = notebooks.compactMap { folder(of: $0) } |
| 339 | if folders.count == notebooks.count { |
| 340 | actions.append(UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { [weak self] _ in |
| 341 | guard let self else { return } |
| 342 | let sheet = UIActivityViewController(activityItems: folders, applicationActivities: nil) |
| 343 | sheet.popoverPresentationController?.barButtonItem = toolbarItems?.last |
| 344 | present(sheet, animated: true) |
| 345 | }) |
| 346 | let deletable = notebooks.allSatisfy { $0.handle != nil } |
| 347 | actions.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: deletable ? .destructive : [.destructive, .disabled]) { [weak self] _ in |
| 348 | self?.delete(notebooks) |
| 349 | }) |
| 350 | } else if notebooks.allSatisfy({ notebook in Notebooks.elsewhere.contains { $0 === notebook } }) { |
| 351 | actions.append(UIAction(title: "Close", image: UIImage(systemName: "xmark.circle")) { [weak self] _ in |
| 352 | for notebook in notebooks { |
| 353 | for scene in UIApplication.shared.connectedScenes { |
| 354 | (scene.delegate as? SceneDelegate)?.close(notebook) |
| 355 | } |
| 356 | Notebooks.remove(notebook) |
| 357 | } |
| 358 | self?.reload() |
| 359 | }) |
| 360 | } |
| 361 | } |
| 362 | let entries = selection.compactMap { item -> (Notebook, String)? in |
| 363 | switch item { |
| 364 | case .section(let id, let path), .group(let id, let path): return notebook(id).map { ($0, path) } |
| 365 | default: return nil |
| 366 | } |
| 367 | } |
| 368 | if let notebook = entries.first?.0, entries.count == selection.count, |
| 369 | entries.allSatisfy({ $0.0 === notebook }) |
| 370 | { |
| 371 | let paths = entries.map(\.1).filter { path in !entries.contains { path.hasPrefix($0.1 + "/") } } |
| 372 | let destinations = notebook.destinations(of: paths[0]).filter { destination in |
| 373 | paths.allSatisfy { notebook.destinations(of: $0).contains(destination) } |
| 374 | } |
| 375 | if !destinations.isEmpty { |
| 376 | actions.append(UIMenu(title: "Move", image: UIImage(systemName: "folder"), children: destinations.map { folder in |
| 377 | UIAction(title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › ")) { [weak self] _ in |
| 378 | self?.setEditing(false, animated: true) |
| 379 | for path in paths { Arranging.place(path, of: notebook, in: folder) } |
| 380 | } |
| 381 | })) |
| 382 | } |
| 383 | } |
| 384 | let action = UIBarButtonItem(title: "Actions", image: UIImage(systemName: "ellipsis.circle"), menu: UIMenu(children: actions)) |
| 385 | action.isEnabled = !actions.isEmpty |
| 386 | toolbarItems = [.flexibleSpace(), action] |
| 387 | navigationController?.setToolbarHidden(false, animated: false) |
| 388 | } |
| 389 | |
| 390 | private func delete(_ notebooks: [Notebook]) { |
| 391 | let alert = UIAlertController( |
| 392 | title: "Delete \(notebooks.count) Notebooks?", |
| 393 | message: "Notebooks that can’t be moved to Recently Deleted will be deleted permanently.", preferredStyle: .alert) |
| 394 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) |
| 395 | alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in |
| 396 | guard let self else { return } |
| 397 | setEditing(false, animated: true) |
| 398 | collectionView.isUserInteractionEnabled = false |
| 399 | for notebook in notebooks { |
| 400 | for scene in UIApplication.shared.connectedScenes { |
| 401 | (scene.delegate as? SceneDelegate)?.close(notebook) |
| 402 | } |
| 403 | } |
| 404 | background({ () -> (String?, [Notebook]) in |
| 405 | // No folder is removed until every notebook has published its edits. |
| 406 | for notebook in notebooks { |
| 407 | guard let handle = notebook.handle else { |
| 408 | return ("\(notebook.name): Open this notebook before deleting it.", notebooks) |
| 409 | } |
| 410 | var error: UnsafeMutablePointer<CChar>? |
| 411 | let closed = sb_library_close(handle, 10, &error) |
| 412 | let problem = take(error) |
| 413 | if !closed { return ("\(notebook.name): \(problem ?? "Some changes haven’t been saved yet.")", notebooks) } |
| 414 | } |
| 415 | var failed: [Notebook] = [] |
| 416 | var problems: [String] = [] |
| 417 | for notebook in notebooks { |
| 418 | guard let folder = self.folder(of: notebook) else { continue } |
| 419 | do { |
| 420 | do { try FileManager.default.trashItem(at: folder, resultingItemURL: nil) } |
| 421 | catch { try FileManager.default.removeItem(at: folder) } |
| 422 | } catch { |
| 423 | failed.append(notebook) |
| 424 | problems.append("\(notebook.name): \(error.localizedDescription)") |
| 425 | } |
| 426 | } |
| 427 | return (problems.isEmpty ? nil : problems.joined(separator: "\n\n"), failed) |
| 428 | }) { [weak self] problem, failed in |
| 429 | self?.collectionView.isUserInteractionEnabled = true |
| 430 | for notebook in notebooks { notebook.pause() } |
| 431 | for notebook in failed { notebook.open { self?.reload() } } |
| 432 | self?.rescan() |
| 433 | if let problem { self?.alert("Can’t Delete", problem) } |
| 434 | } |
| 435 | }) |
| 436 | present(alert, animated: true) |
| 437 | } |
| 438 | |
| 301 | 439 | private func configure(_ cell: UICollectionViewListCell, _ item: Item) { |
| 302 | 440 | var content = cell.defaultContentConfiguration() |
| 303 | 441 | var accessories: [UICellAccessory] = [] |
| ... | ... | @@ -589,7 +727,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 589 | 727 | override func collectionView( |
| 590 | 728 | _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint |
| 591 | 729 | ) -> UIContextMenuConfiguration? { |
| 592 | | guard indexPaths.count == 1, let item = dataSource.itemIdentifier(for: indexPaths[0]) else { return nil } |
| 730 | guard !isEditing, indexPaths.count == 1, let item = dataSource.itemIdentifier(for: indexPaths[0]) else { return nil } |
| 593 | 731 | guard case .notebook(let id) = item, let notebook = notebook(id) else { |
| 594 | 732 | let moves = arrangeMenu(item) |
| 595 | 733 | return moves.isEmpty ? nil : UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: moves) }) |
| ... | ... | @@ -601,6 +739,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 601 | 739 | |
| 602 | 740 | override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { |
| 603 | 741 | guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } |
| 742 | if isEditing { |
| 743 | switch item { |
| 744 | case .notebook, .group, .section: return true |
| 745 | default: return false |
| 746 | } |
| 747 | } |
| 604 | 748 | switch item { |
| 605 | 749 | case .section(let id, let path): |
| 606 | 750 | let tab = notebook(id)?.tabs.first(where: { $0.path == path }) |
| ... | ... | @@ -631,6 +775,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 631 | 775 | } |
| 632 | 776 | |
| 633 | 777 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { |
| 778 | if isEditing { return updateSelectionActions() } |
| 634 | 779 | guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id), |
| 635 | 780 | let tab = notebook.tabs.first(where: { $0.path == path }) |
| 636 | 781 | else { return } |
| ... | ... | @@ -638,6 +783,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 638 | 783 | onOpen?(tab, notebook) |
| 639 | 784 | } |
| 640 | 785 | |
| 786 | override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { |
| 787 | if isEditing { updateSelectionActions() } |
| 788 | } |
| 789 | |
| 641 | 790 | /// Asks for a protected section's password, as OneNote's Protected Section dialog does, |
| 642 | 791 | /// and opens it once unlocked. |
| 643 | 792 | private func unlock(_ tab: Tab, of notebook: Notebook, wrong: Bool) { |
| ... | ... | @@ -846,15 +995,10 @@ final class PagesViewController: UITableViewController { |
| 846 | 995 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") |
| 847 | 996 | navigationItem.searchController = search |
| 848 | 997 | navigationItem.hidesSearchBarWhenScrolling = false |
| 849 | | let add = UIBarButtonItem( |
| 850 | | title: "New Page", image: UIImage(systemName: "plus"), |
| 851 | | primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) }) |
| 852 | 998 | if #available(iOS 26, *) { |
| 853 | 999 | navigationItem.preferredSearchBarPlacement = .integrated |
| 854 | | toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add] |
| 855 | | } else { |
| 856 | | toolbarItems = [.flexibleSpace(), add] |
| 857 | 1000 | } |
| 1001 | updateSelectionActions() |
| 858 | 1002 | NotificationCenter.default.addObserver( |
| 859 | 1003 | self, selector: #selector(changed), name: Section.changed, object: nil) |
| 860 | 1004 | setUpArranging() |
| ... | ... | @@ -885,6 +1029,7 @@ final class PagesViewController: UITableViewController { |
| 885 | 1029 | } |
| 886 | 1030 | |
| 887 | 1031 | func loading(_ tab: Tab) { |
| 1032 | if isEditing { setEditing(false, animated: false) } |
| 888 | 1033 | section = nil |
| 889 | 1034 | items = [] |
| 890 | 1035 | title = tab.name |
| ... | ... | @@ -901,6 +1046,7 @@ final class PagesViewController: UITableViewController { |
| 901 | 1046 | |
| 902 | 1047 | /// Lets go of the section shown, as when its notebook goes. |
| 903 | 1048 | func clear() { |
| 1049 | if isEditing { setEditing(false, animated: false) } |
| 904 | 1050 | section = nil |
| 905 | 1051 | items = [] |
| 906 | 1052 | title = nil |
| ... | ... | @@ -929,6 +1075,7 @@ final class PagesViewController: UITableViewController { |
| 929 | 1075 | |
| 930 | 1076 | func select(_ id: String) { |
| 931 | 1077 | selected = id |
| 1078 | guard !isEditing else { return } |
| 932 | 1079 | if let index = items.firstIndex(where: { $0.id == id }) { |
| 933 | 1080 | tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none) |
| 934 | 1081 | } |
| ... | ... | @@ -939,12 +1086,72 @@ final class PagesViewController: UITableViewController { |
| 939 | 1086 | DispatchQueue.main.async { self.search.searchBar.becomeFirstResponder() } |
| 940 | 1087 | } |
| 941 | 1088 | |
| 1089 | override func setEditing(_ editing: Bool, animated: Bool) { |
| 1090 | super.setEditing(editing, animated: animated) |
| 1091 | for index in tableView.indexPathsForSelectedRows ?? [] { tableView.deselectRow(at: index, animated: false) } |
| 1092 | tableView.dragInteractionEnabled = !editing |
| 1093 | if !editing, let selected { select(selected) } |
| 1094 | updateSelectionActions() |
| 1095 | } |
| 1096 | |
| 1097 | private func updateSelectionActions() { |
| 1098 | guard isEditing else { |
| 1099 | let add = UIBarButtonItem(title: "New Page", image: UIImage(systemName: "plus"), primaryAction: UIAction { [weak self] _ in |
| 1100 | self?.newPage(under: nil) |
| 1101 | }) |
| 1102 | if #available(iOS 26, *) { |
| 1103 | toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add] |
| 1104 | } else { toolbarItems = [.flexibleSpace(), add] } |
| 1105 | return |
| 1106 | } |
| 1107 | let selection = (tableView.indexPathsForSelectedRows ?? []).sorted().map { items[$0.row] } |
| 1108 | let delete = UIBarButtonItem(title: "Delete", image: UIImage(systemName: "trash"), primaryAction: UIAction { [weak self] _ in |
| 1109 | guard let self, let section else { return } |
| 1110 | let remove = { [weak self] in |
| 1111 | self?.setEditing(false, animated: true) |
| 1112 | let ids = selection.filter { item in |
| 1113 | item.version == nil || !selection.contains { $0.version == nil && $0.id == item.row.id } |
| 1114 | }.map(\.id) |
| 1115 | section.delete(ids) |
| 1116 | } |
| 1117 | if selection.contains(where: { $0.version != nil }) { |
| 1118 | let alert = UIAlertController(title: "Delete \(selection.count) Pages?", message: "Conflict pages will be deleted permanently.", preferredStyle: .alert) |
| 1119 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) |
| 1120 | alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in remove() }) |
| 1121 | present(alert, animated: true) |
| 1122 | } else { remove() } |
| 1123 | }) |
| 1124 | delete.tintColor = .systemRed |
| 1125 | delete.isEnabled = !selection.isEmpty |
| 1126 | var destinations: [UIAction] = [] |
| 1127 | if let section, !selection.isEmpty, selection.allSatisfy({ $0.version == nil }) { |
| 1128 | destinations = section.notebook.sections(besides: section.tab.path).map { tab in |
| 1129 | UIAction(title: tab.name) { [weak self] _ in |
| 1130 | self?.setEditing(false, animated: true) |
| 1131 | for item in selection { |
| 1132 | Arranging.move(page: item.id, of: section.notebook, from: section.tab.path, to: tab.path) |
| 1133 | } |
| 1134 | } |
| 1135 | } |
| 1136 | } |
| 1137 | let move = UIBarButtonItem(title: "Move to Section", image: UIImage(systemName: "folder"), menu: UIMenu(children: destinations)) |
| 1138 | move.isEnabled = !destinations.isEmpty |
| 1139 | toolbarItems = [move, .flexibleSpace(), delete] |
| 1140 | navigationController?.setToolbarHidden(false, animated: false) |
| 1141 | } |
| 1142 | |
| 942 | 1143 | private func reload() { |
| 1144 | let selection = tableView.indexPathsForSelectedRows?.map { items[$0.row].id } ?? [] |
| 943 | 1145 | items = (section?.rows ?? []).flatMap { row in |
| 944 | 1146 | [Item(row: row, version: nil)] + row.versions.map { Item(row: row, version: $0) } |
| 945 | 1147 | } |
| 946 | 1148 | tableView.reloadData() |
| 947 | | if let selected { select(selected) } |
| 1149 | if isEditing { |
| 1150 | for (index, item) in items.enumerated() where selection.contains(item.id) { |
| 1151 | tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none) |
| 1152 | } |
| 1153 | updateSelectionActions() |
| 1154 | } else if let selected { select(selected) } |
| 948 | 1155 | var empty = UIContentUnavailableConfiguration.empty() |
| 949 | 1156 | empty.text = "No Pages" |
| 950 | 1157 | contentUnavailableConfiguration = section != nil && items.isEmpty ? empty : nil |
| ... | ... | @@ -961,7 +1168,7 @@ final class PagesViewController: UITableViewController { |
| 961 | 1168 | } |
| 962 | 1169 | |
| 963 | 1170 | private func delete(_ item: Item) { |
| 964 | | section?.delete(item.id) |
| 1171 | section?.delete([item.id]) |
| 965 | 1172 | } |
| 966 | 1173 | |
| 967 | 1174 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count } |
| ... | ... | @@ -997,16 +1204,21 @@ final class PagesViewController: UITableViewController { |
| 997 | 1204 | } |
| 998 | 1205 | |
| 999 | 1206 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { |
| 1207 | if isEditing { return updateSelectionActions() } |
| 1000 | 1208 | guard let section else { return } |
| 1001 | 1209 | selected = items[indexPath.row].id |
| 1002 | 1210 | onOpen?(section, items[indexPath.row].id) |
| 1003 | 1211 | } |
| 1004 | 1212 | |
| 1213 | override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { |
| 1214 | if isEditing { updateSelectionActions() } |
| 1215 | } |
| 1216 | |
| 1005 | 1217 | override func tableView( |
| 1006 | 1218 | _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath |
| 1007 | 1219 | ) -> UISwipeActionsConfiguration? { |
| 1008 | 1220 | // A swipe across the list changes section instead; the context menu deletes. |
| 1009 | | if Prototype.sectionStrip { return nil } |
| 1221 | if isEditing || Prototype.sectionStrip { return nil } |
| 1010 | 1222 | let item = items[indexPath.row] |
| 1011 | 1223 | let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in |
| 1012 | 1224 | self?.delete(item) |
| ... | ... | @@ -1019,6 +1231,7 @@ final class PagesViewController: UITableViewController { |
| 1019 | 1231 | override func tableView( |
| 1020 | 1232 | _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint |
| 1021 | 1233 | ) -> UIContextMenuConfiguration? { |
| 1234 | guard !isEditing else { return nil } |
| 1022 | 1235 | let item = items[indexPath.row] |
| 1023 | 1236 | return UIContextMenuConfiguration(actionProvider: { [weak self] _ in |
| 1024 | 1237 | var actions: [UIMenuElement] = [] |