Browse Source

Merge branch 'master' into enhance/assets-improvements

charlie 1 day ago
parent
commit
e262cf8ddf

+ 1 - 1
clj-e2e/bb.edn

@@ -13,7 +13,7 @@
   prn {:task (clojure "-X clojure.core/prn" cli-opts)}
 
   test {:doc "run tests (ns'es ending in '-basic-test')"
-        :task (do (clojure "-M:test -r \".*\\-basic\\-test$\"")
+        :task (do (clojure "-M:test -r \".*\\-basic\\-test$\" -e fix-me")
                   (System/exit 0))}
 
   rtc-extra-test {:doc "run rtc-extra-test"

+ 25 - 16
clj-e2e/test/logseq/e2e/rtc_basic_test.clj

@@ -16,7 +16,7 @@
 
 (use-fixtures :each fixtures/validate-graph)
 
-(deftest rtc-basic-test
+(deftest ^:fix-me rtc-basic-test
   (let [graph-name (str "rtc-graph-" (.toEpochMilli (java.time.Instant/now)))
         page-names (map #(str "rtc-test-page" %) (range 4))]
     (testing "open 2 app instances, add a rtc graph, check this graph available on other instance"
@@ -38,21 +38,7 @@
                   (page/new-page page-name)))]
           (w/with-page @*page2
             (rtc/wait-tx-update-to remote-tx)
-            (util/search-and-click page-name)))
-        (testing "Page reference created"
-          (let [test-page (str "random page " (random-uuid))
-                block-title (format "test ref [[%s]]" test-page)
-                {:keys [_local-tx remote-tx]}
-                (w/with-page @*page1
-                  (rtc/with-wait-tx-updated
-                    (b/new-block block-title)))]
-            (w/with-page @*page2
-              (rtc/wait-tx-update-to remote-tx)
-              (util/search-and-click test-page)
-              (w/wait-for ".references .ls-block")
-             ;; ensure ref exists
-              (let [refs (w/all-text-contents ".references .ls-block .block-title-wrap")]
-                (is (= refs [block-title])))))))
+            (util/search-and-click page-name))))
       (let [*last-remote-tx (atom nil)]
         (doseq [page-name page-names]
           (let [{:keys [_local-tx remote-tx]}
@@ -65,6 +51,29 @@
           (doseq [page-name page-names]
             (util/search page-name)
             (assert/assert-is-hidden (w/get-by-test-id page-name))))))
+    (testing "Page reference created"
+      (let [page-name "test-page-reference"
+            {:keys [_local-tx remote-tx]}
+            (w/with-page @*page1
+              (rtc/with-wait-tx-updated
+                (page/new-page page-name)))]
+        (w/with-page @*page2
+          (rtc/wait-tx-update-to remote-tx)))
+      (let [test-page (str "random page " (random-uuid))
+            block-title (format "test ref [[%s]]" test-page)
+            {:keys [_local-tx remote-tx]}
+            (w/with-page @*page1
+              (rtc/with-wait-tx-updated
+                (b/new-block block-title)
+                (b/new-block "add new-block to ensure last block saved")))]
+        (w/with-page @*page2
+          (rtc/wait-tx-update-to remote-tx)
+          (util/search-and-click test-page)
+          (w/wait-for ".references .ls-block")
+          ;; ensure ref exists
+          (let [refs (w/all-text-contents ".references .ls-block .block-title-wrap")]
+            (is (= refs [block-title]))))))
+
     (testing "cleanup"
       (w/with-page @*page2
         (graph/remove-remote-graph graph-name)))))

+ 2 - 0
deps/db/.carve/ignore

@@ -27,6 +27,8 @@ logseq.db.sqlite.build/create-blocks
 ;; API
 logseq.db.sqlite.export/build-export
 ;; API
+logseq.db.sqlite.export/validate-export
+;; API
 logseq.db.sqlite.export/build-import
 ;; API
 logseq.db.common.view/get-property-values

+ 53 - 17
deps/db/src/logseq/db/sqlite/export.cljs

@@ -1,7 +1,8 @@
 (ns logseq.db.sqlite.export
   "Builds sqlite.build EDN to represent nodes in a graph-agnostic way.
    Useful for exporting and importing across DB graphs"
-  (:require [clojure.set :as set]
+  (:require [cljs.pprint :as pprint]
+            [clojure.set :as set]
             [clojure.string :as string]
             [clojure.walk :as walk]
             [datascript.core :as d]
@@ -15,7 +16,9 @@
             [logseq.db.frontend.property :as db-property]
             [logseq.db.frontend.property.type :as db-property-type]
             [logseq.db.frontend.schema :as db-schema]
+            [logseq.db.frontend.validate :as db-validate]
             [logseq.db.sqlite.build :as sqlite-build]
+            [logseq.db.test.helper :as db-test]
             [medley.core :as medley]))
 
 ;; Export fns
@@ -477,27 +480,43 @@
        (map :e)
        (map #(d/entity db %))))
 
+(defn- remove-uuid-if-not-ref-given-uuids
+  [ref-uuids m]
+  (if (contains? ref-uuids (:block/uuid m))
+    m
+    (dissoc m :block/uuid :build/keep-uuid?)))
+
 (defn- build-page-export*
-  [db eid page-blocks* options]
+  "When given the :handle-block-uuids option, handle references between blocks"
+  [db eid page-blocks* {:keys [handle-block-uuids?] :as options}]
   (let [page-entity (d/entity db eid)
         page-blocks (->> page-blocks*
                          (sort-by :block/order)
                          ;; Remove property value blocks as they are exported in a block's :build/properties
                          (remove :logseq.property/created-from-property))
-        {:keys [pvalue-uuids] :as blocks-export}
-        (build-blocks-export db page-blocks options)
+        {:keys [pvalue-uuids] :as blocks-export*}
+        (build-blocks-export db page-blocks (cond-> options
+                                              handle-block-uuids?
+                                              (assoc :include-uuid-fn (constantly true))))
+        blocks-export (if handle-block-uuids?
+                        (let [remove-uuid-if-not-ref
+                              (partial remove-uuid-if-not-ref-given-uuids
+                                       (set/union (set pvalue-uuids)
+                                                  (when (set? (:include-uuid-fn options)) (:include-uuid-fn options))))]
+                          (update blocks-export* :blocks #(sqlite-build/update-each-block % remove-uuid-if-not-ref)))
+                        blocks-export*)
         ontology-page-export
         (when (and (not (:ontology-page? options))
                    (or (entity-util/class? page-entity) (entity-util/property? page-entity)))
           (build-mixed-properties-and-classes-export db [page-entity] {:include-uuid? true}))
         class-page-properties-export
         (when-let [props
-                     (and (not (:ontology-page? options))
-                          (entity-util/class? page-entity)
-                          (->> (:logseq.property.class/properties page-entity)
-                               (map :db/ident)
-                               seq))]
-            {:properties (build-export-properties db props {:shallow-copy? true})})
+                   (and (not (:ontology-page? options))
+                        (entity-util/class? page-entity)
+                        (->> (:logseq.property.class/properties page-entity)
+                             (map :db/ident)
+                             seq))]
+          {:properties (build-export-properties db props {:shallow-copy? true})})
         page-block-options (cond-> blocks-export
                              ontology-page-export
                              (merge-export-maps ontology-page-export class-page-properties-export)
@@ -517,6 +536,7 @@
         {:keys [content-ref-ents] :as content-ref-export} (build-content-ref-export db page-blocks*)
         {:keys [pvalue-uuids] :as page-export*}
         (build-page-export* db eid page-blocks* {:include-uuid-fn (:content-ref-uuids content-ref-export)
+                                                 :handle-block-uuids? true
                                                  :include-alias? true})
         page-entity (d/entity db eid)
         uuid-block-export (build-uuid-block-export db pvalue-uuids content-ref-ents {:page-entity page-entity})
@@ -705,9 +725,7 @@
        vec))
 
 (defn remove-uuids-if-not-ref [export-map all-ref-uuids]
-  (let [remove-uuid-if-not-ref (fn [m] (if (contains? all-ref-uuids (:block/uuid m))
-                                         m
-                                         (dissoc m :block/uuid :build/keep-uuid?)))]
+  (let [remove-uuid-if-not-ref (partial remove-uuid-if-not-ref-given-uuids all-ref-uuids)]
     (-> export-map
         (update :classes update-vals remove-uuid-if-not-ref)
         (update :properties update-vals remove-uuid-if-not-ref)
@@ -864,9 +882,10 @@
            e))
        m))))
 
-(defn- ensure-export-is-valid
+(defn- basic-validate-export
   "Checks that export map is usable by sqlite.build including checking that
-   all referenced properties and classes are defined. Checks related to properties and
+   all referenced properties and classes are defined. This validation is not as robust
+   as validate-export. Checks related to properties and
    classes are disabled when :exclude-namespaces is set because those checks can't be done"
   [db export-map* {:keys [graph-options]}]
   (let [export-map (remove-namespaced-keys export-map*)]
@@ -902,10 +921,10 @@
         export-map (patch-invalid-keywords export-map*)]
     (if (get-in options [:graph-options :catch-validation-errors?])
       (try
-        (ensure-export-is-valid db export-map options)
+        (basic-validate-export db export-map options)
         (catch ExceptionInfo e
           (println "Caught error:" e)))
-      (ensure-export-is-valid db export-map options))
+      (basic-validate-export db export-map options))
     (assoc export-map ::export-type export-type)))
 
 ;; Import fns
@@ -1042,3 +1061,20 @@
             (assoc :misc-tx (vec (concat (::graph-files export-map'')
                                          (::kv-values export-map'')))))
         (sqlite-build/build-blocks-tx (remove-namespaced-keys export-map''))))))
+
+(defn validate-export
+  "Validates an export by creating an in-memory DB graph, importing the EDN and validating the graph.
+   Returns a map with a readable :error key if any error occurs"
+  [export-edn]
+  (try
+    (let [import-conn (db-test/create-conn)
+          {:keys [init-tx block-props-tx misc-tx] :as _txs} (build-import export-edn @import-conn {})
+          _ (d/transact! import-conn (concat init-tx block-props-tx misc-tx))
+          validation (db-validate/validate-db! @import-conn)]
+      (when-let [errors (seq (:errors validation))]
+        (js/console.error "Exported edn has the following invalid errors when imported into a new graph:")
+        (pprint/pprint errors)
+        {:error (str "The exported EDN has " (count errors) " error(s). See the javascript console for more details.")}))
+    (catch :default e
+      (js/console.error "Unexpected export-edn validation error:" e)
+      {:error (str "The exported EDN is unexpectedly invalid: " (pr-str (ex-message e)))})))

+ 0 - 1
deps/graph-parser/src/logseq/graph_parser/mldoc.cljc

@@ -8,7 +8,6 @@
                :default ["mldoc" :refer [Mldoc]])
             #?(:org.babashka/nbb [logseq.common.log :as log]
                :default [lambdaisland.glogi :as log])
-            #_:clj-kondo/ignore
             [cljs-bean.core :as bean]
             [clojure.string :as string]
             [goog.object :as gobj]

+ 19 - 0
ios/App/App/AppDelegate.swift

@@ -134,6 +134,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
     // MARK: Navigation operations
     // ---------------------------------------------------------
 
+    private func emptyNavStack(path: String) {
+        let path = normalizedPath(path)
+        guard let nav = navController else { return }
+
+        ignoreRoutePopCount = 0
+        popSnapshotView?.removeFromSuperview()
+        popSnapshotView = nil
+
+        let vc = NativePageViewController(path: path, push: false)
+        pathStack = [path]
+
+        nav.setViewControllers([vc], animated: false)
+        SharedWebViewController.instance.clearPlaceholder()
+        SharedWebViewController.instance.attach(to: vc)
+    }
+
     private func pushIfNeeded(path: String, animated: Bool) {
         let path = normalizedPath(path)
         guard let nav = navController else { return }
@@ -317,6 +333,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
             let navigationType = (notification.userInfo?["navigationType"] as? String) ?? "push"
 
             switch navigationType {
+            case "reset":
+                self.emptyNavStack(path: path)
+
             case "replace":
                 self.replaceTop(path: path)
 

+ 60 - 0
ios/App/App/LiquidTabsPlugin.swift

@@ -1,6 +1,7 @@
 import Foundation
 import Capacitor
 import SwiftUI
+import WebKit
 
 @objc(LiquidTabsPlugin)
 public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin {
@@ -8,6 +9,8 @@ public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin {
     static weak var shared: LiquidTabsPlugin?
 
     private let store = LiquidTabsStore.shared
+    private var keyboardHackScriptInstalled = false
+    private let keyboardHackHandlerName = "keyboardHackKey"
 
     public let identifier = "LiquidTabsPlugin"
     public let jsName = "LiquidTabsPlugin"
@@ -19,6 +22,7 @@ public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin {
     public override func load() {
         super.load()
         LiquidTabsPlugin.shared = self
+        installKeyboardHackScript()
     }
 
     // MARK: - Methods from JS
@@ -79,4 +83,60 @@ public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin {
     func notifySearchChanged(query: String) {
         notifyListeners("searchChanged", data: ["query": query])
     }
+
+    func notifyKeyboardHackKey(key: String) {
+        notifyListeners("keyboardHackKey", data: ["key": key])
+    }
+
+    private func installKeyboardHackScript() {
+        guard !keyboardHackScriptInstalled,
+              let controller = bridge?.webView?.configuration.userContentController else {
+            return
+        }
+
+        keyboardHackScriptInstalled = true
+        controller.removeScriptMessageHandler(forName: keyboardHackHandlerName)
+        controller.add(self, name: keyboardHackHandlerName)
+
+        let source = """
+        (function() {
+          if (window.__logseqKeyboardHackInstalled) return;
+          window.__logseqKeyboardHackInstalled = true;
+          window.addEventListener('keydown', function(e) {
+            var k = null;
+            switch (e.key) {
+              case 'Backspace':
+                k = 'backspace';
+                break;
+              case 'Enter':
+              case 'Return':
+                k = 'enter';
+                break;
+              default:
+                if (e.keyCode === 8) k = 'backspace';
+                else if (e.keyCode === 13) k = 'enter';
+                break;
+            }
+            if (!k) return;
+            try {
+              window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.\(keyboardHackHandlerName).postMessage({ key: k });
+            } catch (_) {}
+          }, true);
+        })();
+        """
+
+        let script = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+        controller.addUserScript(script)
+    }
+}
+
+extension LiquidTabsPlugin: WKScriptMessageHandler {
+    public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+        guard message.name == keyboardHackHandlerName else { return }
+
+        if let body = message.body as? [String: Any],
+           let key = body["key"] as? String {
+            notifyKeyboardHackKey(key: key)
+        }
+    }
 }

+ 45 - 44
ios/App/App/LiquidTabsRootView.swift

@@ -10,8 +10,27 @@ import UIKit
 struct KeyboardHackField: UIViewRepresentable {
     @Binding var shouldShow: Bool
 
+    // Capture Backspace/Enter on the hidden field and forward to JS.
+    class KeyboardHackTextField: UITextField {
+        var onKeyPress: ((String) -> Void)?
+
+        override func deleteBackward() {
+            super.deleteBackward()
+            onKeyPress?("backspace")
+            text = ""
+        }
+
+        override func insertText(_ text: String) {
+            super.insertText(text)
+            if text == "\n" {
+                onKeyPress?("enter")
+            }
+            self.text = ""
+        }
+    }
+
     class Coordinator {
-        let textField = UITextField()
+        let textField = KeyboardHackTextField()
     }
 
     func makeCoordinator() -> Coordinator {
@@ -23,6 +42,9 @@ struct KeyboardHackField: UIViewRepresentable {
         let tf = context.coordinator.textField
         tf.isHidden = true
         tf.keyboardType = .default
+        tf.onKeyPress = { key in
+            LiquidTabsPlugin.shared?.notifyKeyboardHackKey(key: key)
+        }
         container.addSubview(tf)
         return container
     }
@@ -92,7 +114,6 @@ private struct LiquidTabs26View: View {
     let navController: UINavigationController
 
     @State private var searchText: String = ""
-    @State private var isSearchPresented: Bool = false
     @FocusState private var isSearchFocused: Bool
 
     @State private var hackShowKeyboard: Bool = false
@@ -136,6 +157,13 @@ private struct LiquidTabs26View: View {
         return .search
     }
 
+    private func focusSearchField() {
+        // Drive focus (and keyboard) only through searchFocused.
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+            isSearchFocused = true
+        }
+    }
+
     var body: some View {
         if store.tabs.isEmpty {
             // bootstrap webview so JS can configure tabs
@@ -165,7 +193,6 @@ private struct LiquidTabs26View: View {
                     Tab(value: .search, role: .search) {
                         SearchTabHost26(
                             navController: navController,
-                            isSearchFocused: $isSearchFocused,
                             selectedTab: $selectedTab,
                             firstTabId: store.tabs.first?.id,
                             store: store
@@ -174,8 +201,7 @@ private struct LiquidTabs26View: View {
                     }
                 }
                 .searchable(
-                    text: $searchText,
-                    isPresented: $isSearchPresented
+                    text: $searchText
                 )
                 .searchFocused($isSearchFocused)
                 .searchToolbarBehavior(.minimize)
@@ -184,16 +210,13 @@ private struct LiquidTabs26View: View {
                 }
                 .background(Color.logseqBackground)
 
-                // Hidden UITextField that pre-invokes keyboard
+                // Hidden UITextField that pre-invokes keyboard (optional)
                 KeyboardHackField(shouldShow: $hackShowKeyboard)
                     .frame(width: 0, height: 0)
             }
             .onAppear {
                 let initial = initialSelection()
                 selectedTab = initial
-                if case .search = initial {
-                    isSearchPresented = true
-                }
 
                 let appearance = UITabBarAppearance()
                 appearance.configureWithTransparentBackground()
@@ -226,23 +249,20 @@ private struct LiquidTabs26View: View {
 
                 switch newValue {
                 case .search:
-                    isSearchPresented = true
-                case .content:
-                    hackShowKeyboard = false
-                    isSearchFocused = false
-                    isSearchPresented = false
-                }
-            }
-            .onChange(of: isSearchPresented) { presented in
-                if presented {
-                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+                    // Every time we switch to the search tab, re-focus the search
+                    // field so the search bar auto-focuses and keyboard appears.
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                         hackShowKeyboard = true
-                        isSearchFocused = true
                     }
+
                     DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                         hackShowKeyboard = false
                     }
-                } else {
+
+                    focusSearchField()
+
+                case .content:
+                    // Leaving search tab – drop focus and stop hack keyboard.
                     isSearchFocused = false
                     hackShowKeyboard = false
                 }
@@ -262,11 +282,12 @@ private struct LiquidTabs26View: View {
     }
 }
 
-// Search host for 26+ (unchanged)
+// Search host for 26+
+// Only responsible for cancel behaviour and tab switching.
+// It does NOT own the focus anymore.
 @available(iOS 26.0, *)
 private struct SearchTabHost26: View {
     let navController: UINavigationController
-    @FocusState.Binding var isSearchFocused: Bool
     var selectedTab: Binding<LiquidTabsTabSelection>
     let firstTabId: String?
     let store: LiquidTabsStore
@@ -278,14 +299,6 @@ private struct SearchTabHost26: View {
         NavigationStack {
             NativeNavHost(navController: navController)
                 .ignoresSafeArea()
-                .onAppear {
-                    DispatchQueue.main.async {
-                        isSearchFocused = true
-                    }
-                }
-                .onDisappear {
-                    isSearchFocused = false
-                }
                 .onChange(of: isSearching) { searching in
                     if searching {
                         wasSearching = true
@@ -293,7 +306,7 @@ private struct SearchTabHost26: View {
                               case .search = selectedTab.wrappedValue,
                               let firstId = firstTabId {
 
-                        // Cancel logic - Programmatic switch back to first content tab
+                        // User tapped Cancel: switch back to first normal tab.
                         wasSearching = false
                         selectedTab.wrappedValue = .content(0)
                         store.selectedId = firstId
@@ -342,18 +355,6 @@ private struct LiquidTabs16View: View {
                                 store.selectedId = id
                                 LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
                             }
-
-                            // Basic keyboard hack when selecting Search tab
-                            if id == "search" {
-                                DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
-                                    hackShowKeyboard = true
-                                }
-                                DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
-                                    hackShowKeyboard = false
-                                }
-                            } else {
-                                hackShowKeyboard = false
-                            }
                         }
                     )) {
                         // --- Normal dynamic tabs ---

+ 185 - 71
ios/App/App/NativeSelectionActionBarPlugin.swift

@@ -9,8 +9,11 @@ private struct NativeSelectionAction {
     let systemIcon: String?
 
     init?(jsObject: JSObject) {
-        guard let id = jsObject["id"] as? String,
-              let title = jsObject["title"] as? String else { return nil }
+        guard
+            let id = jsObject["id"] as? String,
+            let title = jsObject["title"] as? String
+        else { return nil }
+
         self.id = id
         self.title = title
         self.systemIcon = jsObject["systemIcon"] as? String
@@ -29,24 +32,70 @@ private class NativeSelectionActionBarView: UIView {
         view.translatesAutoresizingMaskIntoConstraints = false
         view.layer.cornerRadius = 16
         view.clipsToBounds = true
-        view.isUserInteractionEnabled = true // ensure the blur container receives touch events
+        view.isUserInteractionEnabled = true
         return view
     }()
 
-    /// Horizontal stack that holds all action buttons.
-    private let stackView: UIStackView = {
+    /// Root horizontal stack that holds scrollable actions on the left and a fixed trailing action on the right.
+    private let rootStack: UIStackView = {
         let stack = UIStackView()
         stack.axis = .horizontal
         stack.alignment = .center
-        stack.distribution = .fillEqually
         stack.spacing = 8
         stack.isLayoutMarginsRelativeArrangement = true
         stack.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
         stack.translatesAutoresizingMaskIntoConstraints = false
-        stack.isUserInteractionEnabled = true // stack should also pass touches to its subviews
+        stack.isUserInteractionEnabled = true
+        return stack
+    }()
+
+    /// Scroll view allowing the main actions to overflow horizontally.
+    private let actionsScrollView: UIScrollView = {
+        let view = UIScrollView()
+        view.showsHorizontalScrollIndicator = false
+        view.showsVerticalScrollIndicator = false
+        view.alwaysBounceHorizontal = true
+        view.contentInsetAdjustmentBehavior = .never
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+
+    /// Stack inside the scroll view for the leading actions.
+    private let actionsStack: UIStackView = {
+        let stack = UIStackView()
+        stack.axis = .horizontal
+        stack.alignment = .center
+        stack.distribution = .fillEqually   // equal widths for main actions
+        stack.spacing = 8
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.isLayoutMarginsRelativeArrangement = true
+        stack.layoutMargins = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2)
         return stack
     }()
 
+    /// Container for the fixed trailing action.
+    private let trailingContainer: UIStackView = {
+        let stack = UIStackView()
+        stack.axis = .horizontal
+        stack.alignment = .center
+        stack.spacing = 8
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.isUserInteractionEnabled = true
+        return stack
+    }()
+
+    private let separator: UIView = {
+        let view = UIView()
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.backgroundColor = UIColor.label.withAlphaComponent(0.1)
+        view.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
+        view.heightAnchor.constraint(greaterThanOrEqualToConstant: 20).isActive = true
+        return view
+    }()
+
+    private var trailingButton: UIControl?
+    private var actionsStackWidthConstraint: NSLayoutConstraint?
+
     // MARK: - Init
 
     override init(frame: CGRect) {
@@ -62,10 +111,12 @@ private class NativeSelectionActionBarView: UIView {
     // MARK: - Public API
 
     /// Present the bar on top of a host view with given actions and colors.
-    func present(on host: UIView,
-                 actions: [NativeSelectionAction],
-                 tintColor: UIColor?,
-                 backgroundColor: UIColor?) {
+    func present(
+        on host: UIView,
+        actions: [NativeSelectionAction],
+        tintColor: UIColor?,
+        backgroundColor: UIColor?
+    ) {
         configure(actions: actions, tintColor: tintColor, backgroundColor: backgroundColor)
         attachIfNeeded(to: host)
         animateInIfNeeded()
@@ -73,18 +124,20 @@ private class NativeSelectionActionBarView: UIView {
 
     /// Dismiss with a small fade/transform animation.
     func dismiss() {
-        UIView.animate(withDuration: 0.15,
-                       delay: 0,
-                       options: [.curveEaseIn],
-                       animations: {
-            self.alpha = 0
-            // Use a small translation for a subtle dismiss effect.
-            self.transform = CGAffineTransform(translationX: 0, y: 8)
-        }, completion: { _ in
-            self.removeFromSuperview()
-            self.transform = .identity
-            self.alpha = 1
-        })
+        UIView.animate(
+            withDuration: 0.15,
+            delay: 0,
+            options: [.curveEaseIn],
+            animations: {
+                self.alpha = 0
+                self.transform = CGAffineTransform(translationX: 0, y: 8)
+            },
+            completion: { _ in
+                self.removeFromSuperview()
+                self.transform = .identity
+                self.alpha = 1
+            }
+        )
     }
 
     // MARK: - Private helpers
@@ -92,9 +145,8 @@ private class NativeSelectionActionBarView: UIView {
     /// Base visual setup: background, shadow, subview hierarchy and constraints.
     private func setupView() {
         backgroundColor = .clear
-        isUserInteractionEnabled = true // container must be interactive
+        isUserInteractionEnabled = true
 
-        // Shadow that appears around the blurred background.
         layer.cornerRadius = 16
         layer.masksToBounds = false
         layer.shadowColor = UIColor.black.cgColor
@@ -110,30 +162,98 @@ private class NativeSelectionActionBarView: UIView {
             blurView.bottomAnchor.constraint(equalTo: bottomAnchor)
         ])
 
-        blurView.contentView.addSubview(stackView)
+        blurView.contentView.addSubview(rootStack)
         NSLayoutConstraint.activate([
-            stackView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
-            stackView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor),
-            stackView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor),
-            stackView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor)
+            rootStack.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
+            rootStack.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor),
+            rootStack.topAnchor.constraint(equalTo: blurView.contentView.topAnchor),
+            rootStack.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor)
         ])
+
+        actionsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        actionsScrollView.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        rootStack.addArrangedSubview(actionsScrollView)
+
+        trailingContainer.setContentHuggingPriority(.required, for: .horizontal)
+        trailingContainer.setContentCompressionResistancePriority(.required, for: .horizontal)
+        rootStack.addArrangedSubview(trailingContainer)
+
+        actionsScrollView.addSubview(actionsStack)
+        NSLayoutConstraint.activate([
+            actionsStack.leadingAnchor.constraint(equalTo: actionsScrollView.contentLayoutGuide.leadingAnchor),
+            actionsStack.trailingAnchor.constraint(equalTo: actionsScrollView.contentLayoutGuide.trailingAnchor),
+            actionsStack.topAnchor.constraint(equalTo: actionsScrollView.contentLayoutGuide.topAnchor),
+            actionsStack.bottomAnchor.constraint(equalTo: actionsScrollView.contentLayoutGuide.bottomAnchor),
+            actionsStack.heightAnchor.constraint(equalTo: actionsScrollView.frameLayoutGuide.heightAnchor)
+        ])
+
+        actionsStackWidthConstraint = actionsStack.widthAnchor.constraint(
+            greaterThanOrEqualTo: actionsScrollView.frameLayoutGuide.widthAnchor
+        )
+        actionsStackWidthConstraint?.priority = .defaultHigh
+        actionsStackWidthConstraint?.isActive = true
+
+        trailingContainer.addArrangedSubview(separator)
+        trailingContainer.isHidden = true
     }
 
     /// Rebuilds the stack buttons for the current set of actions.
-    private func configure(actions: [NativeSelectionAction],
-                           tintColor: UIColor?,
-                           backgroundColor: UIColor?) {
-        // Remove old buttons.
-        stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
-
+    private func configure(
+        actions: [NativeSelectionAction],
+        tintColor: UIColor?,
+        backgroundColor: UIColor?
+    ) {
         let tint = tintColor ?? .label
-        // Background color behind the blur. This helps match the Logseq background.
         blurView.backgroundColor = backgroundColor ?? UIColor.logseqBackground.withAlphaComponent(0.94)
+        blurView.contentView.backgroundColor = .clear
+
+        let mainActions = Array(actions.dropLast())
+        let trailingAction = actions.last
 
-        actions.forEach { action in
+        // Clear existing main actions.
+        actionsStack.arrangedSubviews.forEach { sub in
+            actionsStack.removeArrangedSubview(sub)
+            sub.removeFromSuperview()
+        }
+        actionsScrollView.isHidden = mainActions.isEmpty
+
+        // Rebuild main actions.
+        mainActions.forEach { action in
             let button = makeButton(for: action, tintColor: tint)
-            stackView.addArrangedSubview(button)
+            actionsStack.addArrangedSubview(button)
         }
+
+        configureTrailing(
+            action: trailingAction,
+            tintColor: tint,
+            showSeparator: !mainActions.isEmpty
+        )
+    }
+
+    private func configureTrailing(
+        action: NativeSelectionAction?,
+        tintColor: UIColor,
+        showSeparator: Bool
+    ) {
+        if let existingButton = trailingButton {
+            trailingContainer.removeArrangedSubview(existingButton)
+            existingButton.removeFromSuperview()
+            trailingButton = nil
+        }
+
+        guard let action = action else {
+            trailingContainer.isHidden = true
+            separator.isHidden = true
+            return
+        }
+
+        trailingContainer.isHidden = false
+        separator.isHidden = !showSeparator
+
+        let button = makeButton(for: action, tintColor: tintColor)
+        button.setContentHuggingPriority(.required, for: .horizontal)
+        trailingContainer.addArrangedSubview(button)
+        trailingButton = button
     }
 
     /// Attaches the bar to the given host view, pinned to the bottom with safe area.
@@ -142,7 +262,7 @@ private class NativeSelectionActionBarView: UIView {
         removeFromSuperview()
 
         host.addSubview(self)
-        host.bringSubviewToFront(self) // ensure this bar is above other subviews (e.g. WKWebView)
+        host.bringSubviewToFront(self)
         translatesAutoresizingMaskIntoConstraints = false
 
         NSLayoutConstraint.activate([
@@ -154,18 +274,20 @@ private class NativeSelectionActionBarView: UIView {
 
     /// Simple fade-in animation when the bar appears.
     private func animateInIfNeeded() {
-        // Only animate if we're currently visible and not already animated.
         guard alpha == 1 else { return }
 
         alpha = 0
         transform = CGAffineTransform(translationX: 0, y: 8)
-        UIView.animate(withDuration: 0.2,
-                       delay: 0,
-                       options: [.curveEaseOut, .allowUserInteraction],
-                       animations: {
-            self.alpha = 1
-            self.transform = .identity
-        })
+        UIView.animate(
+            withDuration: 0.2,
+            delay: 0,
+            options: [.curveEaseOut, .allowUserInteraction],
+            animations: {
+                self.alpha = 1
+                self.transform = .identity
+            },
+            completion: nil
+        )
     }
 
     /// Creates a single button for an action (icon + label in a vertical stack).
@@ -175,7 +297,6 @@ private class NativeSelectionActionBarView: UIView {
         control.translatesAutoresizingMaskIntoConstraints = false
         control.isUserInteractionEnabled = true
 
-        // Icon
         let iconView = UIImageView()
         iconView.contentMode = .scaleAspectFit
         iconView.tintColor = tintColor
@@ -184,7 +305,6 @@ private class NativeSelectionActionBarView: UIView {
         iconView.heightAnchor.constraint(equalToConstant: 22).isActive = true
         iconView.widthAnchor.constraint(equalToConstant: 22).isActive = true
 
-        // Title label
         let label = UILabel()
         label.text = action.title
         label.textAlignment = .center
@@ -192,13 +312,12 @@ private class NativeSelectionActionBarView: UIView {
         label.textColor = tintColor
         label.numberOfLines = 1
 
-        // Vertical stack containing icon + label.
         let column = UIStackView(arrangedSubviews: [iconView, label])
         column.axis = .vertical
         column.alignment = .center
         column.spacing = 6
         column.translatesAutoresizingMaskIntoConstraints = false
-        column.isUserInteractionEnabled = false // let the UIControl handle touches instead of the stack
+        column.isUserInteractionEnabled = false
 
         control.addSubview(column)
         NSLayoutConstraint.activate([
@@ -208,7 +327,6 @@ private class NativeSelectionActionBarView: UIView {
             column.bottomAnchor.constraint(equalTo: control.bottomAnchor, constant: -4)
         ])
 
-        // Add targets for tap handling.
         control.addTarget(self, action: #selector(handleTap(_:)), for: .touchUpInside)
 
         return control
@@ -216,7 +334,6 @@ private class NativeSelectionActionBarView: UIView {
 
     // MARK: - Touch handling
 
-    /// Called on touchUpInside, triggers the callback with the action id.
     @objc private func handleTap(_ sender: UIControl) {
         guard let id = sender.accessibilityIdentifier else { return }
         onActionTapped?(id)
@@ -236,7 +353,6 @@ public class NativeSelectionActionBarPlugin: CAPPlugin, CAPBridgedPlugin {
 
     private var actionBar: NativeSelectionActionBarView?
 
-    /// Called from JS to show/update the selection bar.
     @objc func present(_ call: CAPPluginCall) {
         let rawActions = call.getArray("actions", JSObject.self) ?? []
         let actions = rawActions.compactMap(NativeSelectionAction.init(jsObject:))
@@ -251,7 +367,6 @@ public class NativeSelectionActionBarPlugin: CAPPlugin, CAPBridgedPlugin {
                 return
             }
 
-            // If actions are empty, hide the bar instead.
             guard !actions.isEmpty else {
                 self.actionBar?.dismiss()
                 self.actionBar = nil
@@ -264,17 +379,18 @@ public class NativeSelectionActionBarPlugin: CAPPlugin, CAPBridgedPlugin {
                 print("action id", id)
                 self?.notifyListeners("action", data: ["id": id])
             }
-            bar.present(on: host,
-                        actions: actions,
-                        tintColor: tintColor,
-                        backgroundColor: backgroundColor)
+            bar.present(
+                on: host,
+                actions: actions,
+                tintColor: tintColor,
+                backgroundColor: backgroundColor
+            )
             self.actionBar = bar
 
             call.resolve()
         }
     }
 
-    /// Called from JS to hide the selection bar.
     @objc func dismiss(_ call: CAPPluginCall) {
         DispatchQueue.main.async {
             self.actionBar?.dismiss()
@@ -283,7 +399,6 @@ public class NativeSelectionActionBarPlugin: CAPPlugin, CAPBridgedPlugin {
         }
     }
 
-    /// Attempts to find the appropriate host view to attach the bar to.
     private func hostView() -> UIView? {
         if let parent = bridge?.viewController?.parent?.view {
             return parent
@@ -295,7 +410,6 @@ public class NativeSelectionActionBarPlugin: CAPPlugin, CAPBridgedPlugin {
 // MARK: - Helpers
 
 private extension String {
-    /// Converts a hex color string (e.g. "#RRGGBB" or "#RRGGBBAA") to UIColor.
     func toUIColor(defaultColor: UIColor) -> UIColor {
         var hexString = self.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
         if hexString.hasPrefix("#") {
@@ -308,19 +422,19 @@ private extension String {
         }
 
         switch hexString.count {
-        case 6: // RRGGBB
+        case 6:
             return UIColor(
-                red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
-                green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
-                blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
+                red:   CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
+                green: CGFloat((rgbValue & 0x00FF00) >> 8)  / 255.0,
+                blue:  CGFloat(rgbValue & 0x0000FF)         / 255.0,
                 alpha: 1.0
             )
-        case 8: // RRGGBBAA
+        case 8:
             return UIColor(
-                red: CGFloat((rgbValue & 0xFF000000) >> 24) / 255.0,
+                red:   CGFloat((rgbValue & 0xFF000000) >> 24) / 255.0,
                 green: CGFloat((rgbValue & 0x00FF0000) >> 16) / 255.0,
-                blue: CGFloat((rgbValue & 0x0000FF00) >> 8) / 255.0,
-                alpha: CGFloat(rgbValue & 0x000000FF) / 255.0
+                blue:  CGFloat((rgbValue & 0x0000FF00) >> 8)  / 255.0,
+                alpha: CGFloat(rgbValue & 0x000000FF)         / 255.0
             )
         default:
             return defaultColor

+ 2 - 1
src/main/frontend/components/editor.cljs

@@ -817,7 +817,8 @@
                                       (when (= (util/ekey e) "Escape")
                                         (editor-on-hide state :esc e))))
                :auto-focus true
-               :auto-capitalize "off"
+               :auto-capitalize (if (util/mobile?) "sentences" "off")
+               :auto-correct (if (util/mobile?) "true" "false")
                :class heading-class}
                (some? parent-block)
                (assoc :parentblockid (str (:block/uuid parent-block)))

+ 16 - 5
src/main/frontend/components/export.cljs

@@ -20,7 +20,8 @@
             [logseq.db :as ldb]
             [logseq.shui.ui :as shui]
             [promesa.core :as p]
-            [rum.core :as rum]))
+            [rum.core :as rum]
+            [logseq.db.sqlite.export :as sqlite-export]))
 
 (rum/defcs auto-backup < rum/reactive
   {:init (fn [state]
@@ -177,9 +178,18 @@
                       :selected-nodes
                       {:node-ids (mapv #(vector :block/uuid %) root-block-uuids-or-page-uuid)}
                       {})]
-    (state/<invoke-db-worker :thread-api/export-edn
-                             (state/get-current-repo)
-                             (merge {:export-type export-type} export-args))))
+    (p/let [export-edn (state/<invoke-db-worker :thread-api/export-edn
+                                                (state/get-current-repo)
+                                                (merge {:export-type export-type} export-args))]
+      ;; Don't validate :block for now b/c it requires more setup
+      (if (#{:page :selected-nodes} export-type)
+        (if-let [error (:error (sqlite-export/validate-export export-edn))]
+          (do
+            (js/console.log "Invalid export EDN:")
+            (pprint/pprint export-edn)
+            {:export-edn-error error})
+          export-edn)
+        export-edn))))
 
 (defn- get-zoom-level
   [page-uuid]
@@ -283,7 +293,8 @@
                       :on-click #(do (reset! *export-block-type :edn)
                                      (p/let [result (<export-edn-helper top-level-uuids export-type)
                                              pull-data (with-out-str (pprint/pprint result))]
-                                       (when-not (:export-edn-error result)
+                                       (if (:export-edn-error result)
+                                         (notification/show! (:export-edn-error result) :error)
                                          (reset! *content pull-data))))))])
       (if (= :png tp)
         [:div.flex.items-center.justify-center.relative

+ 9 - 8
src/main/frontend/components/quick_add.cljs

@@ -49,18 +49,19 @@
                          (when-not mobile? (shui/shortcut ["mod" "e"]))
                          "Add to today")]]
         [:div.ls-quick-add.flex.flex-1.flex-col.w-full.gap-4
-         [:div.flex.flex-row.justify-between.gap-4.items-center
-          {:class (if mobile?
-                    "pt-4"
-                    "border-b pb-4")}
-          [:div.font-medium
-           "Quick add"]
-          (when mobile? add-button)]
+         (when-not (util/mobile?)
+           [:div.flex.flex-row.justify-between.gap-4.items-center
+            {:class (if mobile?
+                      "pt-4"
+                      "border-b pb-4")}
+            [:div.font-medium
+             "Quick add"]
+            add-button])
          (if mobile?
            [:main#app-container-wrapper.ls-fold-button-on-right
             [:div#app-container.pt-2
              [:div#main-container.flex.flex-1
-              [:div.w-full
+              [:div.w-full.mt-4
                (page-blocks add-page)]]]]
            [:div.content {:class "block -ml-6"}
             (page-blocks add-page)])

+ 18 - 24
src/main/frontend/components/repo.cljs

@@ -478,25 +478,14 @@
 (rum/defc new-db-graph
   []
   (let [[creating-db? set-creating-db?] (hooks/use-state false)
-        rtc-group? (user-handler/rtc-group?)
-        [cloud? set-cloud?] (hooks/use-state rtc-group?)
+        [cloud? set-cloud?] (hooks/use-state false)
         [e2ee-rsa-key-ensured? set-e2ee-rsa-key-ensured?] (hooks/use-state nil)
-        input-ref (hooks/create-ref)
-        [input-value set-input-value!] (hooks/use-state "")]
-
+        input-ref (hooks/create-ref)]
     (hooks/use-effect!
      (fn []
-       (let [token (state/get-auth-id-token)
-             user-uuid (user-handler/user-uuid)]
-         (when (and rtc-group? cloud? (not e2ee-rsa-key-ensured?))
-           (when (and token user-uuid)
-             (-> (p/let [rsa-key-pair (state/<invoke-db-worker :thread-api/get-user-rsa-key-pair token user-uuid)]
-                   (set-e2ee-rsa-key-ensured? (some? rsa-key-pair)))
-                 (p/catch (fn [e]
-                            (log/error :get-user-rsa-key-pair e)
-                            e)))))))
+       (when-let [^js input (hooks/deref input-ref)]
+         (js/setTimeout #(.focus input) 32)))
      [])
-
     (letfn [(new-db-f [graph-name]
               (when-not (or (string/blank? graph-name)
                             creating-db?)
@@ -526,22 +515,28 @@
        (shui/input
         {:disabled creating-db?
          :ref input-ref
-         :auto-focus true
-         :placeholder "Graph name"
+         :placeholder "your graph name"
          :on-key-down submit!
-         :on-change (fn [e] (set-input-value! (util/evalue e)))
-         :value input-value
          :autoComplete "off"})
        (when (user-handler/rtc-group?)
          [:div.flex.flex-col
           [:div.flex.flex-row.items-center.gap-1
            (shui/checkbox
             {:id "rtc-sync"
-             :checked cloud?
+             :value cloud?
              :on-checked-change
              (fn []
-               (let [v (boolean (not cloud?))]
-                 (set-cloud? v)))})
+               (let [v (boolean (not cloud?))
+                     token (state/get-auth-id-token)
+                     user-uuid (user-handler/user-uuid)]
+                 (set-cloud? v)
+                 (when (and (true? v) (not e2ee-rsa-key-ensured?))
+                   (when (and token user-uuid)
+                     (-> (p/let [rsa-key-pair (state/<invoke-db-worker :thread-api/get-user-rsa-key-pair token user-uuid)]
+                           (set-e2ee-rsa-key-ensured? (some? rsa-key-pair)))
+                         (p/catch (fn [e]
+                                    (log/error :get-user-rsa-key-pair e)
+                                    e)))))))})
            [:label.opacity-70.text-sm
             {:for "rtc-sync"}
             "Use Logseq Sync?"]]
@@ -550,8 +545,7 @@
              {:for "rtc-sync"}
              "Need to init E2EE settings first, Settings > Encryption"])])
        (shui/button
-        {:disabled (or (and cloud? (not e2ee-rsa-key-ensured?))
-                       (string/blank? input-value))
+        {:disabled (and cloud? (not e2ee-rsa-key-ensured?))
          :on-click #(submit! % true)
          :on-key-down submit!}
         (if creating-db?

+ 19 - 18
src/main/frontend/components/svg.cljs

@@ -392,21 +392,22 @@
    [:svg.icon {:width size :height size :viewBox "0 0 16 16" :fill "currentColor"}
     [:path {:fill-rule "evenodd" :clip-rule "evenodd" :d "M7.116 8l-4.558 4.558.884.884L8 8.884l4.558 4.558.884-.884L8.884 8l4.558-4.558-.884-.884L8 7.116 3.442 2.558l-.884.884L7.116 8z"}]]))
 
-(defn audio-lines
-  ([] (audio-lines 16))
-  ([size]
-   [:svg.icon
-    {:stroke "currentColor",
-     :fill "none",
-     :stroke-linejoin "round",
-     :width size,
-     :height "24"
-     :stroke-linecap "round",
-     :stroke-width "2.5",
-     :viewBox "0 0 24 24"}
-    [:path {:d "M2 10v3"}]
-    [:path {:d "M6 6v11"}]
-    [:path {:d "M10 3v18"}]
-    [:path {:d "M14 8v7"}]
-    [:path {:d "M18 5v13"}]
-    [:path {:d "M22 10v3"}]]))
+(comment
+  (defn audio-lines
+    ([] (audio-lines 16))
+    ([size]
+     [:svg.icon
+      {:stroke "currentColor",
+       :fill "none",
+       :stroke-linejoin "round",
+       :width size,
+       :height "24"
+       :stroke-linecap "round",
+       :stroke-width "2.5",
+       :viewBox "0 0 24 24"}
+      [:path {:d "M2 10v3"}]
+      [:path {:d "M6 6v11"}]
+      [:path {:d "M10 3v18"}]
+      [:path {:d "M14 8v7"}]
+      [:path {:d "M18 5v13"}]
+      [:path {:d "M22 10v3"}]])))

+ 27 - 17
src/main/frontend/handler/db_based/export.cljs

@@ -8,7 +8,19 @@
             [frontend.util :as util]
             [frontend.util.page :as page-util]
             [goog.dom :as gdom]
-            [promesa.core :as p]))
+            [promesa.core :as p]
+            [logseq.db.sqlite.export :as sqlite-export]))
+
+(defn- <export-edn-helper
+  "Gets export-edn and validates export for smaller exports. Copied from component.export/<export-edn-helper"
+  [export-args]
+  (p/let [export-edn (state/<invoke-db-worker :thread-api/export-edn (state/get-current-repo) export-args)]
+    (if-let [error (:error (sqlite-export/validate-export export-edn))]
+      (do
+        (js/console.log "Invalid export EDN:")
+        (pprint/pprint export-edn)
+        {:export-edn-error error})
+      export-edn)))
 
 (defn ^:export export-block-data []
   ;; Use editor state to locate most recent block
@@ -24,27 +36,25 @@
     (notification/show! "No block found" :warning)))
 
 (defn export-view-nodes-data [rows {:keys [group-by?]}]
-  (p/let [result (state/<invoke-db-worker :thread-api/export-edn
-                                          (state/get-current-repo)
-                                          {:export-type :view-nodes
-                                           :rows rows
-                                           :group-by? group-by?})
+  (p/let [result (<export-edn-helper {:export-type :view-nodes
+                                      :rows rows
+                                      :group-by? group-by?})
           pull-data (with-out-str (pprint/pprint result))]
-    (when-not (:export-edn-error result)
-      (.writeText js/navigator.clipboard pull-data)
-      (println pull-data)
-      (notification/show! "Copied view nodes' data!" :success))))
+    (if (:export-edn-error result)
+        (notification/show! (:export-edn-error result) :error)
+        (do (.writeText js/navigator.clipboard pull-data)
+            (println pull-data)
+            (notification/show! "Copied view nodes' data!" :success)))))
 
 (defn ^:export export-page-data []
   (if-let [page-id (page-util/get-current-page-id)]
-    (p/let [result (state/<invoke-db-worker :thread-api/export-edn
-                                            (state/get-current-repo)
-                                            {:export-type :page :page-id page-id})
+    (p/let [result (<export-edn-helper {:export-type :page :page-id page-id})
             pull-data (with-out-str (pprint/pprint result))]
-      (when-not (:export-edn-error result)
-        (.writeText js/navigator.clipboard pull-data)
-        (println pull-data)
-        (notification/show! "Copied page's data!" :success)))
+      (if (:export-edn-error result)
+        (notification/show! (:export-edn-error result) :error)
+        (do (.writeText js/navigator.clipboard pull-data)
+            (println pull-data)
+            (notification/show! "Copied page's data!" :success))))
     (notification/show! "No page found" :warning)))
 
 (defn ^:export export-graph-ontology-data []

+ 35 - 22
src/main/frontend/handler/editor.cljs

@@ -2570,12 +2570,14 @@
   (some? (dom/closest el ".block-editor")))
 
 (defn keydown-new-block-handler [^js e]
-  (let [state (get-state)]
-    (when (or (nil? (.-target e)) (inside-of-editor-block (.-target e)))
+  (let [state (get-state)
+        target (when e (.-target e))]
+    (when (or (nil? target)
+              (inside-of-editor-block target))
       (if (or (state/doc-mode-enter-for-new-line?) (inside-of-single-block (rum/dom-node state)))
         (keydown-new-line)
         (do
-          (.preventDefault e)
+          (when e (.preventDefault e))
           (keydown-new-block state))))))
 
 (defn keydown-new-line-handler [e]
@@ -2854,6 +2856,32 @@
         (delete-and-update
          input current-pos (util/safe-inc-current-pos-from-start (.-value input) current-pos))))))
 
+(defn delete-block-when-zero-pos!
+  [^js e]
+  (let [^js input (state/get-input)
+        current-pos (cursor/pos input)]
+    (when (zero? current-pos)
+      (util/stop e)
+      (let [repo (state/get-current-repo)
+            block* (state/get-edit-block)
+            block (db/entity (:db/id block*))
+            value (gobj/get input "value")
+            editor-state (get-state)
+            custom-query? (get-in editor-state [:config :custom-query?])
+            top-block? (= (:db/id (or (ldb/get-left-sibling block) (:block/parent block)))
+                          (:db/id (:block/page block)))
+            single-block? (if e (inside-of-single-block (.-target e)) false)
+            root-block? (= (:block.temp/container block) (str (:block/uuid block)))]
+        (when (and (not (and top-block? (not (string/blank? value))))
+                   (not root-block?)
+                   (not single-block?)
+                   (not custom-query?))
+          (if (own-order-number-list? block)
+            (p/do!
+             (save-current-block!)
+             (remove-block-own-order-list-type! block))
+            (delete-block! repo)))))))
+
 (defn keydown-backspace-handler
   [cut? e]
   (let [^js input (state/get-input)
@@ -2866,13 +2894,7 @@
                          (util/nth-safe value (dec current-pos)))
             selected-start (util/get-selection-start input)
             selected-end (util/get-selection-end input)
-            block (state/get-edit-block)
-            block (db/entity (:db/id block))
-            repo (state/get-current-repo)
-            top-block? (= (:db/id (or (ldb/get-left-sibling block) (:block/parent block)))
-                          (:db/id (:block/page block)))
-            single-block? (inside-of-single-block (.-target e))
-            root-block? (= (:block.temp/container block) (str (:block/uuid block)))]
+            repo (state/get-current-repo)]
         (block-handler/mark-last-input-time! repo)
         (cond
           (not= selected-start selected-end)
@@ -2883,18 +2905,9 @@
             (delete-and-update input selected-start selected-end))
 
           (zero? current-pos)
-          (let [editor-state (get-state)
-                custom-query? (get-in editor-state [:config :custom-query?])]
-            (util/stop e)
-            (when (and (not (and top-block? (not (string/blank? value))))
-                       (not root-block?)
-                       (not single-block?)
-                       (not custom-query?))
-              (if (own-order-number-list? block)
-                (p/do!
-                 (save-current-block!)
-                 (remove-block-own-order-list-type! block))
-                (delete-block! repo))))
+          (when-not (mobile-util/native-ios?)
+            ;; native iOS handled by `mobile.bottom-tabs/add-keyboard-hack-listener!`
+            (delete-block-when-zero-pos! e))
 
           (and (> current-pos 0)
                (contains? #{commands/command-trigger commands/command-ask}

+ 5 - 6
src/main/frontend/handler/page.cljs

@@ -334,10 +334,10 @@
                (not (:graph/importing @state/state))
                (not (state/loading-files? repo))
                (not config/publishing?))
-      (state/set-today! (date/today))
-      (when (or (config/db-based-graph? repo)
-                (config/local-file-based-graph? repo))
-        (if-let [title (date/today)]
+      (when-let [title (date/today)]
+        (state/set-today! title)
+        (when (or (config/db-based-graph? repo)
+                  (config/local-file-based-graph? repo))
           (let [today-page (util/page-name-sanity-lc title)
                 format (state/get-preferred-format repo)
                 db-based? (config/db-based-graph? repo)
@@ -361,8 +361,7 @@
                                        (fs/read-file repo-dir file-rpath))]
                   (when (or (not file-exists?)
                             (and file-exists? (string/blank? file-content)))
-                    (create-f))))))
-          (notification/show! "Failed to parse date to journal name." :error))))))
+                    (create-f)))))))))))
 
 (defn open-today-in-sidebar
   []

+ 5 - 5
src/main/frontend/state.cljs

@@ -623,11 +623,11 @@ should be done through this fn in order to get global config and config defaults
 
 (defn get-ref-open-blocks-level
   []
-  (or
-   (when-let [value (:ref/default-open-blocks-level (get-config))]
-     (when (pos-int? value)
-       (min value 9)))
-   2))
+  (if-let [value (:ref/default-open-blocks-level (get-config))]
+    (if (and (int? value) (>= value 0))
+      (min value 9)
+      2)
+    2))
 
 (defn get-export-bullet-indentation
   []

+ 10 - 8
src/main/frontend/worker/rtc/core.cljs

@@ -374,7 +374,7 @@
                                  (reset! *last-stop-exception e)
                                  (log/info :rtc-loop-task e)
                                  (when-not (or (instance? Cancelled e) (= "missionary.Cancelled" (ex-message e)))
-                                   (println (.-stack e)))
+                                   (log/info :rtc-loop-task-ex-stack (.-stack e)))
                                  (when (= :rtc.exception/ws-timeout (some-> e ex-data :type))
                                    ;; if fail reason is websocket-timeout, try to restart rtc
                                    (worker-state/<invoke-main-thread :thread-api/rtc-start-request repo))))
@@ -430,8 +430,7 @@
 (defn rtc-stop
   []
   (when-let [canceler (:canceler @*rtc-loop-metadata)]
-    (canceler)
-    (reset! *rtc-loop-metadata empty-rtc-loop-metadata)))
+    (canceler)))
 
 (defn rtc-toggle-auto-push
   []
@@ -519,11 +518,13 @@
                     *online-users *last-stop-exception]}
             (m/?< rtc-loop-metadata-flow)]
         (try
-          (when (and repo rtc-state-flow *rtc-auto-push? *rtc-lock')
+          (if-not (and repo rtc-state-flow *rtc-auto-push? *rtc-lock')
+            (m/amb)
             (m/?<
              (m/latest
               (fn [rtc-state rtc-auto-push? rtc-remote-profile?
-                   rtc-lock online-users pending-local-ops-count pending-asset-ops-count [local-tx remote-tx]]
+                   rtc-lock online-users pending-local-ops-count pending-asset-ops-count
+                   [local-tx remote-tx] last-stop-ex]
                 {:graph-uuid graph-uuid
                  :local-graph-schema-version (db-schema/schema-version->string local-graph-schema-version)
                  :remote-graph-schema-version (db-schema/schema-version->string remote-graph-schema-version)
@@ -537,14 +538,15 @@
                  :auto-push? rtc-auto-push?
                  :remote-profile? rtc-remote-profile?
                  :online-users online-users
-                 :last-stop-exception-ex-data (some-> *last-stop-exception deref ex-data)})
+                 :last-stop-exception-ex-data (some-> last-stop-ex ex-data)})
               rtc-state-flow
               (m/watch *rtc-auto-push?) (m/watch *rtc-remote-profile?)
               (m/watch *rtc-lock') (m/watch *online-users)
               (client-op/create-pending-block-ops-count-flow repo)
               (client-op/create-pending-asset-ops-count-flow repo)
-              (rtc-log-and-state/create-local&remote-t-flow graph-uuid))))
-          (catch Cancelled _))))))
+              (rtc-log-and-state/create-local&remote-t-flow graph-uuid)
+              (m/watch *last-stop-exception))))
+          (catch Cancelled _ (m/amb)))))))
 
 (def ^:private create-get-state-flow (c.m/throttle 300 create-get-state-flow*))
 

+ 47 - 19
src/main/mobile/bottom_tabs.cljs

@@ -1,10 +1,12 @@
 (ns mobile.bottom-tabs
   "iOS bottom tabs"
   (:require [cljs-bean.core :as bean]
+            [clojure.string :as string]
             [frontend.handler.editor :as editor-handler]
-            [frontend.handler.route :as route-handler]
+            [frontend.state :as state]
             [frontend.util :as util]
             [logseq.common.util :as common-util]
+            [mobile.navigation :as mobile-nav]
             [mobile.state :as mobile-state]))
 
 ;; Capacitor plugin instance:
@@ -55,42 +57,68 @@
    liquid-tabs
    "searchChanged"
    (fn [data]
-      ;; data is like { query: string }
+       ;; data is like { query: string }
      (f (.-query data)))))
 
+(defn add-keyboard-hack-listener!
+  "Listen for Backspace or Enter while the invisible keyboard field is focused."
+  []
+  (.addListener
+   liquid-tabs
+   "keyboardHackKey"
+   (fn [data]
+     ;; data is like { key: string }
+     (when-let [k (.-key data)]
+       (case k
+         "backspace"
+         (editor-handler/delete-block-when-zero-pos! nil)
+         "enter"
+         (when-let [input (state/get-input)]
+           (let [value (.-value input)]
+             (when (string/blank? value)
+               (editor-handler/keydown-new-block-handler nil))))
+         nil)))))
+
+(defonce *previous-tab (atom nil))
 (defonce add-tab-listeners!
   (do
     (add-tab-selected-listener!
      (fn [tab]
-       (reset! mobile-state/*search-input "")
-       (when-not (= tab "quick-add")
-         (mobile-state/set-tab! tab))
-       (case tab
-         "home"
-         (do
-           (route-handler/redirect-to-home!)
-           (util/scroll-to-top false))
-         "quick-add"
+       (if (= tab "capture")
          (editor-handler/show-quick-add)
-         ;; TODO: support longPress detection
-         ;; (if (= "longPress" interaction)
-         ;;   (state/pub-event! [:mobile/start-audio-record])
-         ;;   (editor-handler/show-quick-add))
-         nil)))
+         (let [exit-search? (= "search" @*previous-tab)]
+           (when-not (= tab @*previous-tab)
+             (when-not exit-search?
+               (mobile-nav/reset-route!))
+             (mobile-state/set-tab! tab))
+
+           (case tab
+             "home"
+             (util/scroll-to-top false)
+             ;; TODO: support longPress detection
+             ;; (if (= "longPress" interaction)
+             ;;   (state/pub-event! [:mobile/start-audio-record])
+             ;;   (editor-handler/show-quick-add))
+             nil)
+           (reset! *previous-tab tab)))))
+
     (add-watch mobile-state/*tab ::select-tab
                (fn [_ _ _old new]
                  (when new (select! new))))
     (add-search-listener!
      (fn [q]
-      ;; wire up search handler
+       ;; wire up search handler
        (js/console.log "Native search query" q)
        (reset! mobile-state/*search-input q)
-       (reset! mobile-state/*search-last-input-at (common-util/time-ms))))))
+       (reset! mobile-state/*search-last-input-at (common-util/time-ms))
+       (when (= :page (state/get-current-route))
+         (mobile-nav/reset-route!))))
+    (add-keyboard-hack-listener!)))
 
 (defn configure
   []
   (configure-tabs
    [{:id "home"       :title "Home"       :systemImage "house" :role "normal"}
     {:id "favorites"  :title "Favorites"  :systemImage "star"  :role "normal"}
-    {:id "quick-add"  :title "Capture"    :systemImage "tray"  :role "normal"}
+    {:id "capture"    :title "Capture"    :systemImage "tray"  :role "normal"}
     {:id "settings"   :title "Settings"   :systemImage "gear"  :role "normal"}]))

+ 13 - 11
src/main/mobile/components/app.cljs

@@ -35,9 +35,9 @@
   [db-restoring?]
   (if db-restoring?
     [:div.space-y-2.mt-8.mx-0.opacity-75
-     (shui/skeleton {:class "h-10 w-full mb-6 bg-gray-200"})
-     (shui/skeleton {:class "h-6 w-full bg-gray-200"})
-     (shui/skeleton {:class "h-6 w-full bg-gray-200"})]
+     (shui/skeleton {:class "h-10 w-full mb-6"})
+     (shui/skeleton {:class "h-6 w-full"})
+     (shui/skeleton {:class "h-6 w-full"})]
     (journals)))
 
 (rum/defc home < rum/reactive rum/static
@@ -87,15 +87,17 @@
 
 (rum/defc other-page
   [view tab route-match]
-  [:div#main-content-container.px-5.ls-layer
-   (if view
-     (view route-match)
-     (case (keyword tab)
-       :home nil
-       :favorites (favorites/favorites)
+  (let [tab' (keyword tab)]
+    [:div#main-content-container.px-5.ls-layer
+     (case tab'
        :settings (settings/page)
-       :search (search/search)
-       nil))])
+       (if view
+         (view route-match)
+         (case tab'
+           :home nil
+           :favorites (favorites/favorites)
+           :search (search/search)
+           nil)))]))
 
 (rum/defc main-content < rum/static
   [tab route-match]

+ 52 - 18
src/main/mobile/components/editor_toolbar.cljs

@@ -2,8 +2,8 @@
   "Mobile editor toolbar"
   (:require [frontend.colors :as colors]
             [frontend.commands :as commands]
-            [frontend.components.svg :as svg]
             [frontend.handler.editor :as editor-handler]
+            [frontend.handler.history :as history]
             [frontend.mobile.camera :as mobile-camera]
             [frontend.mobile.haptics :as haptics]
             [frontend.mobile.util :as mobile-util]
@@ -64,17 +64,35 @@
   {:id (if indent? "indent" "outdent")
    :title (if indent? "Indent" "Outdent")
    :system-icon (if indent? "arrow.right" "arrow.left")
-   :icon (if indent? "arrow-right-to-arc" "arrow-left-to-arc")
    :handler (fn []
               (blur-if-compositing)
               (editor-handler/indent-outdent indent?))})
 
+(defn- undo-action
+  []
+  {:id "undo"
+   :title "Undo"
+   :system-icon "arrow.uturn.backward"
+   :event? true
+   :handler (fn []
+              (blur-if-compositing)
+              (history/undo!))})
+
+(defn- redo-action
+  []
+  {:id "redo"
+   :title "Redo"
+   :system-icon "arrow.uturn.forward"
+   :event? true
+   :handler (fn []
+              (blur-if-compositing)
+              (history/redo!))})
+
 (defn- todo-action
   []
   {:id "todo"
    :title "Todo"
    :system-icon "checkmark.square"
-   :icon "checkbox"
    :event? true
    :handler (fn []
               (blur-if-compositing)
@@ -85,7 +103,6 @@
   {:id "tag"
    :title "Tag"
    :system-icon "number"
-   :icon "hash"
    :event? true
    :handler #(insert-text "#" {})})
 
@@ -95,7 +112,6 @@
    :title "Reference"
    ;; TODO: create sf symbol for brackets
    :system-icon "parentheses"
-   :icon "brackets"
    :event? true
    :handler insert-page-ref!})
 
@@ -104,7 +120,6 @@
   {:id "slash"
    :title "Slash"
    :system-icon "command"
-   :icon "command"
    :event? true
    :handler #(insert-text "/" {})})
 
@@ -113,7 +128,6 @@
   {:id "camera"
    :title "Photo"
    :system-icon "camera"
-   :icon "camera"
    :event? true
    :handler #(when-let [parent-id (state/get-edit-input-id)]
                (mobile-camera/embed-photo parent-id))})
@@ -123,7 +137,6 @@
   {:id "audio"
    :title "Audio"
    :system-icon "waveform"
-   :icon (svg/audio-lines 20)
    :handler #(recorder/record!)})
 
 (defn- keyboard-action
@@ -131,26 +144,47 @@
   {:id "keyboard"
    :title "Hide"
    :system-icon "keyboard.chevron.compact.down"
-   :icon "keyboard-show"
    :handler #(p/do!
               (editor-handler/save-current-block!)
               (state/clear-edit!)
               (mobile-init/keyboard-hide))})
 
+(defn- capture-action
+  []
+  {:id "capture"
+   :title "Capture"
+   :system-icon "paperplane"
+   :handler (fn []
+              (state/clear-edit!)
+              (mobile-init/keyboard-hide)
+              (editor-handler/quick-add-blocks!))})
+
 (defn- toolbar-actions
   [quick-add?]
   (let [audio (audio-action)
         keyboard (keyboard-action)
-        main-actions (cond-> [(todo-action)
-                              (indent-outdent-action false)
-                              (indent-outdent-action true)
-                              (tag-action)
-                              (camera-action)
-                              (page-ref-action)
-                              (slash-action)]
-                       (not quick-add?) (conj audio))]
+        main-actions (if quick-add?
+                       [(undo-action)
+                        (todo-action)
+                        audio
+                        (camera-action)
+                        (tag-action)
+                        (page-ref-action)
+                        (indent-outdent-action false)
+                        (indent-outdent-action true)
+                        (redo-action)]
+                       [(undo-action)
+                        (todo-action)
+                        (indent-outdent-action false)
+                        (indent-outdent-action true)
+                        (tag-action)
+                        (camera-action)
+                        (page-ref-action)
+                        audio
+                        (slash-action)
+                        (redo-action)])]
     {:main main-actions
-     :trailing (if quick-add? audio keyboard)}))
+     :trailing (if quick-add? (capture-action) keyboard)}))
 
 (defn- action->native
   [{:keys [id title system-icon]}]

+ 9 - 9
src/main/mobile/components/popup.cljs

@@ -4,6 +4,7 @@
             [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
             [frontend.ui :as ui]
+            [frontend.util :as util]
             [logseq.shui.popup.core :as shui-popup]
             [logseq.shui.ui :as shui]
             [mobile.state :as mobile-state]
@@ -37,28 +38,27 @@
 (defn- dismiss-native-sheet!
   []
   (when-let [^js plugin mobile-util/native-bottom-sheet]
-    (mobile-state/set-popup! nil)
-    (reset! *last-popup-data nil)
     (.dismiss plugin #js {})))
 
 (defn- handle-native-sheet-state!
   [^js data]
   (let [presenting? (.-presenting data)
+        presented? (.-presented data)
         dismissing? (.-dismissing data)]
     (cond
       presenting?
+      (when (mobile-state/quick-add-open?)
+        (util/mobile-keep-keyboard-open false))
+
+      presented?
       (when (mobile-state/quick-add-open?)
         (editor-handler/quick-add-open-last-block!))
 
       dismissing?
       (when (some? @mobile-state/*popup-data)
-        (let [quick-add? (mobile-state/quick-add-open?)
-              current-tab @mobile-state/*tab]
-          (state/pub-event! [:mobile/clear-edit])
-          (mobile-state/set-popup! nil)
-          (reset! *last-popup-data nil)
-          (when (and current-tab quick-add?)
-            (mobile-state/set-tab! current-tab))))
+        (state/pub-event! [:mobile/clear-edit])
+        (mobile-state/set-popup! nil)
+        (reset! *last-popup-data nil))
 
       :else
       nil)))

+ 10 - 5
src/main/mobile/components/selection_toolbar.cljs

@@ -30,28 +30,34 @@
   (let [close! close-selection-bar!]
     [{:id "copy"
       :label "Copy"
-      :icon "copy"
       :system-icon "doc.on.doc"
       :handler (fn []
                  (editor-handler/copy-selection-blocks false)
                  (close!))}
+     {:id "outdent"
+      :label "Outdent"
+      :system-icon "arrow.left"
+      :handler (fn []
+                 (editor-handler/on-tab :left))}
+     {:id "indent"
+      :label "Indent"
+      :system-icon "arrow.right"
+      :handler (fn []
+                 (editor-handler/on-tab :right))}
      {:id "delete"
       :label "Delete"
-      :icon "cut"
       :system-icon "trash"
       :handler (fn []
                  (editor-handler/cut-selection-blocks false {:mobile-action-bar? true})
                  (close!))}
      {:id "copy-ref"
       :label "Copy ref"
-      :icon "registered"
       :system-icon "r.square"
       :handler (fn []
                  (editor-handler/copy-block-refs)
                  (close!))}
      {:id "copy-url"
       :label "Copy url"
-      :icon "link"
       :system-icon "link"
       :handler (fn []
                  (let [current-repo (state/get-current-repo)
@@ -62,7 +68,6 @@
                  (close!))}
      {:id "unselect"
       :label "Unselect"
-      :icon "x"
       :system-icon "xmark"
       :handler (fn []
                  (state/clear-selection!)

+ 2 - 2
src/main/mobile/components/ui.cljs

@@ -23,8 +23,8 @@
   ([t]
    [:input.absolute.top-4.left-0.w-1.h-1.opacity-0
     {:id (str "keep-keyboard-open-input" t)
-     :auto-capitalize "off"
-     :auto-correct "false"}]))
+     :auto-capitalize "sentences"
+     :auto-correct "true"}]))
 
 (rum/defc notification-clear-all
   []

+ 3 - 1
src/main/mobile/core.cljs

@@ -85,7 +85,9 @@
          (if pop?
            (route-handler/set-route-match! route)
            (reset! *route-timeout
-                   (js/setTimeout #(route-handler/set-route-match! route) 200)))))
+                   (js/setTimeout
+                    #(route-handler/set-route-match! route)
+                    200)))))
 
      ;; set to false to enable HistoryAPI
      {:use-fragment true})))

+ 21 - 10
src/main/mobile/navigation.cljs

@@ -1,6 +1,7 @@
 (ns mobile.navigation
   "Native navigation bridge for mobile (iOS)."
   (:require [clojure.string :as string]
+            [frontend.handler.route :as route-handler]
             [frontend.mobile.util :as mobile-util]
             [lambdaisland.glogi :as log]
             [promesa.core :as p]))
@@ -26,6 +27,14 @@
       (compare-and-set! initialised? false true) "replace" ;; first load
       :else "push")))
 
+(defn- notify-route-payload!
+  [payload]
+  (-> (.routeDidChange mobile-util/ui-local (clj->js payload))
+      (p/catch (fn [err]
+                 (log/warn :mobile-native-navigation/route-report-failed
+                           {:error err
+                            :payload payload})))))
+
 (defn notify-route-change!
   "Inform native iOS layer of a route change to keep native stack in sync.
    {route {to keyword, path-params map, query-params map}
@@ -35,13 +44,15 @@
   (when (and (mobile-util/native-ios?)
              mobile-util/ui-local)
     (let [nav-type (navigation-type push)
-          payload (clj->js (cond-> {:navigationType nav-type
-                                    :push (not= nav-type "replace")}
-                             route (assoc :route route)
-                             (or path (.-hash js/location))
-                             (assoc :path (strip-fragment (or path (.-hash js/location))))))]
-      (-> (.routeDidChange mobile-util/ui-local payload)
-          (p/catch (fn [err]
-                     (log/warn :mobile-native-navigation/route-report-failed
-                               {:error err
-                                :payload payload})))))))
+          payload (cond-> {:navigationType nav-type
+                           :push (not= nav-type "replace")}
+                    route (assoc :route route)
+                    (or path (.-hash js/location))
+                    (assoc :path (strip-fragment (or path (.-hash js/location)))))]
+      (notify-route-payload! payload))))
+
+(defn reset-route!
+  []
+  (route-handler/redirect-to-home! false)
+  (notify-route-payload!
+   {:navigationType "reset"}))