Kaynağa Gözat

enhance(mobile): native search view

Tienson Qin 3 ay önce
ebeveyn
işleme
803a373638

+ 27 - 1
ios/App/App/LiquidTabsPlugin.swift

@@ -16,7 +16,8 @@ public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin {
     public let jsName = "LiquidTabsPlugin"
     public let pluginMethods: [CAPPluginMethod] = [
       CAPPluginMethod(name: "configureTabs", returnType: CAPPluginReturnPromise),
-      CAPPluginMethod(name: "selectTab", returnType: CAPPluginReturnPromise)
+      CAPPluginMethod(name: "selectTab", returnType: CAPPluginReturnPromise),
+      CAPPluginMethod(name: "updateNativeSearchResults", returnType: CAPPluginReturnPromise),
     ]
 
     public override func load() {
@@ -74,6 +75,27 @@ public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin {
         call.resolve()
     }
 
+    /// Update native search results list from JS.
+    /// { results: [{ id, title, subtitle? }] }
+    @objc func updateNativeSearchResults(_ call: CAPPluginCall) {
+        guard let resultDicts = call.getArray("results", JSObject.self) else {
+            call.reject("Missing 'results'")
+            return
+        }
+
+        let mapped: [NativeSearchResult] = resultDicts.compactMap { dict in
+            guard let id = dict["id"] as? String,
+                  let title = dict["title"] as? String else {
+                return nil
+            }
+            let subtitle = dict["subtitle"] as? String
+            return NativeSearchResult(id: id, title: title, subtitle: subtitle)
+        }
+
+        store.updateSearchResults(mapped)
+        call.resolve()
+    }
+
     // MARK: - Events to JS
 
     func notifyTabSelected(id: String) {
@@ -88,6 +110,10 @@ public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin {
         notifyListeners("keyboardHackKey", data: ["key": key])
     }
 
+    func openResult(id: String) {
+        notifyListeners("openSearchResultBlock", data: ["id": id])
+    }
+
     private func installKeyboardHackScript() {
         guard !keyboardHackScriptInstalled,
               let controller = bridge?.webView?.configuration.userContentController else {

+ 84 - 25
ios/App/App/LiquidTabsRootView.swift

@@ -113,7 +113,6 @@ private struct LiquidTabs26View: View {
     @StateObject private var store = LiquidTabsStore.shared
     let navController: UINavigationController
 
-    @State private var searchText: String = ""
     @FocusState private var isSearchFocused: Bool
 
     @State private var hackShowKeyboard: Bool = false
@@ -135,6 +134,13 @@ private struct LiquidTabs26View: View {
         )
     }
 
+    private var searchTextBinding: Binding<String> {
+        Binding(
+            get: { store.searchText },
+            set: { store.searchText = $0 }
+        )
+    }
+
     private func handleRetap(on selection: LiquidTabsTabSelection) {
         print("User re-tapped tab: \(selection)")
         navController.popToRootViewController(animated: true)
@@ -219,10 +225,13 @@ private struct LiquidTabs26View: View {
                         .ignoresSafeArea()
                     }
                 }
-                .searchable(text: $searchText)
+                .searchable(text: searchTextBinding)
                 .searchFocused($isSearchFocused)
                 .searchToolbarBehavior(.minimize)
-                .onChange(of: searchText) { query in
+                .onChange(of: store.searchText) { query in
+                    if query.isEmpty {
+                        store.searchResults = []
+                    }
                     LiquidTabsPlugin.shared?.notifySearchChanged(query: query)
                 }
                 .background(Color.logseqBackground)
@@ -295,35 +304,47 @@ private struct LiquidTabs26View: View {
 // Search host for 26+
 // Only responsible for cancel behaviour and tab switching.
 // It does NOT own the focus anymore.
+@available(iOS 26.0, *)
+private enum SearchRoute: Hashable {
+    case result(String)
+}
+
 @available(iOS 26.0, *)
 private struct SearchTabHost26: View {
     let navController: UINavigationController
     var selectedTab: Binding<LiquidTabsTabSelection>
     let firstTabId: String?
-    let store: LiquidTabsStore
+    @ObservedObject var store: LiquidTabsStore
 
     @Environment(\.isSearching) private var isSearching
     @State private var wasSearching: Bool = false
 
     var body: some View {
         NavigationStack {
-            NativeNavHost(navController: navController)
-                .ignoresSafeArea()
-                .onChange(of: isSearching) { searching in
-                    if searching {
-                        wasSearching = true
-                    } else if wasSearching,
-                              case .search = selectedTab.wrappedValue,
-                              let firstId = firstTabId {
-
-                        // User tapped Cancel: switch back to first normal tab.
-                        wasSearching = false
-                        selectedTab.wrappedValue = .content(0)
-                        store.selectedId = firstId
-                    }
-                }
+            ZStack {
+                Color.logseqBackground
+                  .ignoresSafeArea()
+
+                SearchResultsContent(
+                    navController: navController,
+                    store: store
+                )
+            }
         }
+          .onChange(of: isSearching) { searching in
+              if searching {
+                  wasSearching = true
+              } else if wasSearching,
+                        case .search = selectedTab.wrappedValue,
+                        let firstId = firstTabId {
+
+                  wasSearching = false
+                  selectedTab.wrappedValue = .content(0)
+                  store.selectedId = firstId
+              }
+          }
     }
+
 }
 
 // MARK: - iOS 16–25 implementation
@@ -333,9 +354,15 @@ private struct LiquidTabs16View: View {
     @StateObject private var store = LiquidTabsStore.shared
     let navController: UINavigationController
 
-    @State private var searchText: String = ""
     @State private var hackShowKeyboard: Bool = false
 
+    private var searchTextBinding: Binding<String> {
+        Binding(
+            get: { store.searchText },
+            set: { store.searchText = $0 }
+        )
+    }
+
     var body: some View {
         ZStack {
             Color.logseqBackground.ignoresSafeArea()
@@ -380,7 +407,8 @@ private struct LiquidTabs16View: View {
                         // --- 🔍 SEARCH TAB (iOS 16–25) ---
                         SearchTab16Host(
                             navController: navController,
-                            searchText: $searchText
+                            searchText: searchTextBinding,
+                            store: store
                         )
                         .ignoresSafeArea()
                         .tabItem {
@@ -425,13 +453,18 @@ private struct LiquidTabs16View: View {
 private struct SearchTab16Host: View {
     let navController: UINavigationController
     @Binding var searchText: String
+    @ObservedObject var store: LiquidTabsStore
 
     var body: some View {
         NavigationStack {
             ZStack {
-                // Main content (fills whole screen)
-                NativeNavHost(navController: navController)
-                    .ignoresSafeArea()
+                Color.logseqBackground
+                  .ignoresSafeArea()
+
+                SearchResultsContent(
+                    navController: navController,
+                    store: store
+                )
 
                 // Bottom search bar
                 VStack {
@@ -462,10 +495,36 @@ private struct SearchTab16Host: View {
                     .padding(.bottom, 12)
                 }
             }
-            .navigationBarHidden(true)
         }
         .onChange(of: searchText) { query in
             LiquidTabsPlugin.shared?.notifySearchChanged(query: query)
         }
     }
 }
+
+private struct SearchResultsContent: View {
+    let navController: UINavigationController
+    @ObservedObject var store: LiquidTabsStore
+
+    var body: some View {
+        List(store.searchResults) { result in
+            NavigationLink(value: result) {
+                Text(result.title)
+                  .foregroundColor(.primary)
+                  .padding(.vertical, 8)
+                  .contentShape(Rectangle())   // improves tap area
+            }
+              .listRowBackground(Color.clear)
+        }
+          .scrollContentBackground(.hidden)
+          .scrollDismissesKeyboard(.immediately)
+          .navigationTitle("Search")
+          .navigationDestination(for: NativeSearchResult.self) { result in
+              NativeNavHost(navController: navController)
+                .ignoresSafeArea()
+                .onAppear {
+                    LiquidTabsPlugin.shared?.openResult(id: result.id)
+                }
+          }
+    }
+}

+ 16 - 0
ios/App/App/LiquidTabsStore.swift

@@ -1,6 +1,12 @@
 import SwiftUI
 import Combine
 
+struct NativeSearchResult: Identifiable, Hashable {
+    let id: String          // page or block id from JS
+    let title: String
+    let subtitle: String?   // optional: page path, snippet, etc.
+}
+
 struct LiquidTab: Identifiable, Equatable {
     let id: String
     let title: String
@@ -18,6 +24,10 @@ final class LiquidTabsStore: ObservableObject {
 
     @Published var tabs: [LiquidTab] = []
     @Published var selectedId: String?
+    @Published var searchText: String = ""
+
+    // Native-rendered search results supplied by JS.
+    @Published var searchResults: [NativeSearchResult] = []
 
     // Helper to get a stable selection if JS forgets
     func effectiveSelectedId() -> String? {
@@ -30,4 +40,10 @@ final class LiquidTabsStore: ObservableObject {
     func tab(for id: String) -> LiquidTab? {
         tabs.first(where: { $0.id == id })
     }
+
+    func updateSearchResults(_ results: [NativeSearchResult]) {
+        DispatchQueue.main.async {
+            self.searchResults = results
+        }
+    }
 }

+ 23 - 4
src/main/mobile/bottom_tabs.cljs

@@ -3,10 +3,13 @@
   (: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.state :as mobile-state]))
+            [mobile.search :as mobile-search]
+            [mobile.state :as mobile-state]
+            [promesa.core :as p]))
 
 ;; Capacitor plugin instance:
 ;; Make sure the plugin is registered as `LiquidTabs` on the native side.
@@ -32,6 +35,12 @@
    liquid-tabs
    #js {:id id}))
 
+(defn update-native-search-results!
+  "Send native search result list to the iOS plugin."
+  [results]
+  (when (and (util/capacitor?) liquid-tabs (.-updateNativeSearchResults liquid-tabs))
+    (.updateNativeSearchResults liquid-tabs (clj->js {:results results}))))
+
 (defn add-tab-selected-listener!
   "Listen to native tab selection.
 
@@ -60,6 +69,16 @@
        ;; data is like { query: string }
      (f (.-query data)))))
 
+(defn add-search-result-item-listener!
+  []
+  (.addListener
+   liquid-tabs
+   "openSearchResultBlock"
+   (fn [data]
+     (when-let [id (.-id data)]
+       (when-not (string/blank? id)
+         (route-handler/redirect-to-page! id {:push false}))))))
+
 (defn add-keyboard-hack-listener!
   "Listen for Backspace or Enter while the invisible keyboard field is focused."
   []
@@ -97,9 +116,9 @@
        (js/console.log "Native search query" q)
        (reset! mobile-state/*search-input q)
        (reset! mobile-state/*search-last-input-at (common-util/time-ms))
-       (comment
-         (when (= :page (state/get-current-route))
-           (mobile-nav/reset-route!)))))
+       (p/let [result (mobile-search/search q)]
+         (update-native-search-results! result))))
+    (add-search-result-item-listener!)
     (add-keyboard-hack-listener!)))
 
 (defn configure

+ 1 - 2
src/main/mobile/components/app.cljs

@@ -21,7 +21,6 @@
             [mobile.components.graphs :as graphs]
             [mobile.components.header :as mobile-header]
             [mobile.components.popup :as popup]
-            [mobile.components.search :as search]
             [mobile.components.selection-toolbar :as selection-toolbar]
             [mobile.components.ui :as ui-component]
             [mobile.state :as mobile-state]
@@ -106,7 +105,7 @@
        (cond
          (= tab "graphs") (graphs/page)
          (= tab "go to") (favorites/favorites)
-         (= tab "search") (search/search)
+         (= tab "search") nil
          (= tab "capture") (capture)))]))
 
 (rum/defc main-content < rum/static

+ 1 - 1
src/main/mobile/components/header.cljs

@@ -143,7 +143,7 @@
 (defn- configure-native-top-bar!
   [repo {:keys [tab title route-name route-view sync-color favorited?]}]
   (when (mobile-util/native-ios?)
-    (let [hidden? false
+    (let [hidden? (= tab "search")
           rtc-indicator? (and repo
                               (ldb/get-graph-rtc-uuid (db/get-db))
                               (user-handler/logged-in?))

+ 0 - 100
src/main/mobile/components/search.cljs

@@ -1,100 +0,0 @@
-(ns mobile.components.search
-  "Mobile search"
-  (:require [clojure.string :as string]
-            [frontend.components.cmdk.core :as cmdk]
-            [frontend.handler.route :as route-handler]
-            [frontend.handler.search :as search-handler]
-            [frontend.search :as search]
-            [frontend.state :as state]
-            [frontend.ui :as ui]
-            [frontend.util :as util]
-            [logseq.shui.hooks :as hooks]
-            [logseq.shui.ui :as shui]
-            [mobile.state :as mobile-state]
-            [promesa.core :as p]
-            [rum.core :as rum]))
-
-(defn- search-blocks
-  [input]
-  (p/let [repo (state/get-current-repo)
-          blocks (search/block-search repo input
-                                      {:limit 100 :built-in? true})
-          blocks (remove nil? blocks)
-          blocks (search/fuzzy-search blocks input {:limit 100
-                                                    :extract-fn :block/title})
-          items (keep (fn [block]
-                        (if (:page? block)
-                          (assoc (cmdk/page-item repo block) :page? true)
-                          (cmdk/block-item repo block nil input))) blocks)]
-    items))
-
-(rum/defc ^:large-vars/cleanup-todo search
-  []
-  (let [[input set-input!] (mobile-state/use-search-input)
-        [search-result set-search-result!] (hooks/use-state nil)
-        [last-input-at _set-last-input-at!] (mobile-state/use-search-last-input-at)
-        [recents set-recents!] (hooks/use-state (search-handler/get-recents))
-        result search-result]
-
-    (hooks/use-effect!
-     (fn []
-       (when-not (string/blank? input)
-         (let [*timeout (atom nil)]
-           (p/let [result (search-blocks input)]
-             (set-search-result! (or result []))
-             (when (seq result)
-               (reset! *timeout
-                       (js/setTimeout
-                        (fn []
-                          (let [now (util/time-ms)]
-                            (when (and last-input-at (>= (- now last-input-at) 2000))
-                              (search-handler/add-recent! input)
-                              (set-recents! (search-handler/get-recents)))))
-                        2000))))
-           #(when-let [timeout @*timeout]
-              (js/clearTimeout timeout)))))
-     [(hooks/use-debounced-value input 150)])
-
-    [:div.app-search
-     (when (and (string/blank? input) (seq recents))
-       [:div
-        [:div.px-2.font-medium.text-muted-foreground
-         [:div.flex.flex-item.items-center.justify-between
-          "Recent"
-          (shui/button
-           {:variant :text
-            :size :sm
-            :class "text-muted-foreground flex justify-end pr-1"
-            :on-click (fn []
-                        (search-handler/clear-recents!)
-                        (set-recents! nil))}
-           "Clear")]]
-
-        (for [item recents]
-          [:div
-           (ui/menu-link
-            {:on-click #(set-input! item)}
-            item)])])
-     (if (seq result)
-       [:ul
-        {:class (when (and (not (string/blank? input))
-                           (seq search-result))
-                  "as-results")}
-        (for [{:keys [page? icon text header source-block]} result]
-          (let [block source-block]
-            [:li.flex.gap-1
-             {:on-click (fn []
-                          (when-let [id (:block/uuid block)]
-                            (route-handler/redirect-to-page! (str id))))}
-             [:div.flex.flex-col.gap-1.py-1
-              (when header
-                [:div.opacity-60
-                 header])
-              [:div.flex.flex-row.items-start.gap-1
-               (when (and page? icon) (ui/icon icon {:size 15
-                                                     :class "text-muted-foreground mt-1"}))
-               [:div text]]]]))]
-       (when (= result [])
-         (when-not (string/blank? input)
-           [:div.font-medium.text-muted-foreground
-            "No results"])))]))

+ 61 - 0
src/main/mobile/search.cljs

@@ -0,0 +1,61 @@
+(ns mobile.search
+  "Mobile search"
+  (:require [clojure.string :as string]
+            [frontend.components.cmdk.core :as cmdk]
+            [frontend.db :as db]
+            [frontend.search :as search]
+            [frontend.state :as state]
+            [promesa.core :as p]))
+
+(defn- search-blocks
+  [input]
+  (p/let [repo (state/get-current-repo)
+          blocks (search/block-search repo input
+                                      {:limit 100
+                                       :built-in? true
+                                       :enable-snippet? false})
+          blocks (remove nil? blocks)
+          blocks (search/fuzzy-search blocks input {:limit 100
+                                                    :extract-fn :block/title})
+          items (keep (fn [block]
+                        (if (:page? block)
+                          (assoc (cmdk/page-item repo block) :page? true)
+                          (cmdk/block-item repo block nil input))) blocks)]
+    items))
+
+(defn- block->page-name
+  [block]
+  (let [page (:block/page block)
+        page-block (cond
+                     (map? page) page
+                     (uuid? page) (db/entity [:block/uuid page])
+                     (number? page) (db/entity page)
+                     :else nil)]
+    (:block/title page-block)))
+
+(defn safe-truncate [s]
+  (if (<= (count s) 256)
+    s
+    (str (subs s 0 256) "…")))
+
+(defn- native-search-result
+  [item]
+  (let [block (:source-block item)
+        id (:block/uuid block)
+        title (some-> block :block/title string/trim)
+        subtitle (some-> block block->page-name string/trim)]
+    (when (and id (not (string/blank? title)))
+      (let [short-title (when title (safe-truncate title))]
+        {:id (str id)
+         :title short-title
+         :subtitle (when-not (string/blank? subtitle) subtitle)}))))
+
+(defn search
+  [input]
+  (if (string/blank? input)
+    (p/resolved [])
+    (p/let [items (search-blocks input)]
+      (some->> items
+               (keep native-search-result)
+               distinct
+               vec))))