Browse Source

enhance: native selection bar

Tienson Qin 1 week ago
parent
commit
ec32fe3590

+ 4 - 0
ios/App/App.xcodeproj/project.pbxproj

@@ -42,6 +42,7 @@
 		D3D62A0A275C92880003FBDC /* FileContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D62A09275C92880003FBDC /* FileContainer.swift */; };
 		D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = D3D62A0B275C928F0003FBDC /* FileContainer.m */; };
 		D3F4A5B62F1234567890ABCE /* NativeBottomSheetPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F4A5B62F1234567890ABCD /* NativeBottomSheetPlugin.swift */; };
+		D3F4A5B62F1234567890ABD1 /* NativeSelectionActionBarPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F4A5B62F1234567890ABCF /* NativeSelectionActionBarPlugin.swift */; };
 		FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF327BDFEDE00F3206B /* FsWatcher.swift */; };
 		FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF527BDFEF500F3206B /* FsWatcher.m */; };
 		FE96D6102A1B811A001ECE32 /* SharedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE96D60F2A1B811A001ECE32 /* SharedData.swift */; };
@@ -123,6 +124,7 @@
 		D3D62A09275C92880003FBDC /* FileContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileContainer.swift; sourceTree = "<group>"; };
 		D3D62A0B275C928F0003FBDC /* FileContainer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FileContainer.m; sourceTree = "<group>"; };
 		D3F4A5B62F1234567890ABCD /* NativeBottomSheetPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBottomSheetPlugin.swift; sourceTree = "<group>"; };
+		D3F4A5B62F1234567890ABCF /* NativeSelectionActionBarPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSelectionActionBarPlugin.swift; sourceTree = "<group>"; };
 		DE5650F4AD4E2242AB9C012D /* Pods-Logseq.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Logseq.debug.xcconfig"; path = "Target Support Files/Pods-Logseq/Pods-Logseq.debug.xcconfig"; sourceTree = "<group>"; };
 		FE647FF327BDFEDE00F3206B /* FsWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FsWatcher.swift; sourceTree = "<group>"; };
 		FE647FF527BDFEF500F3206B /* FsWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FsWatcher.m; sourceTree = "<group>"; };
@@ -211,6 +213,7 @@
 				CBF2D2D92DE83CB0006338BE /* UILocalPlugin.swift */,
 				ABCDEF0123456789000000AA /* NativeTopBarPlugin.swift */,
 				D3F4A5B62F1234567890ABCD /* NativeBottomSheetPlugin.swift */,
+				D3F4A5B62F1234567890ABCF /* NativeSelectionActionBarPlugin.swift */,
 				5FF86329283B5ADB0047731B /* Utils.swift */,
 				5FF8632B283B5BFD0047731B /* Utils.m */,
 				D32752BF2754C5AB0039291C /* AppDebug.entitlements */,
@@ -463,6 +466,7 @@
 				CBF2D2DA2DE83CB8006338BE /* UILocalPlugin.swift in Sources */,
 				ABCDEF0123456789000000AB /* NativeTopBarPlugin.swift in Sources */,
 				D3F4A5B62F1234567890ABCE /* NativeBottomSheetPlugin.swift in Sources */,
+				D3F4A5B62F1234567890ABD1 /* NativeSelectionActionBarPlugin.swift in Sources */,
 				A1B2C3D41E2F3A4B5C6D7E90 /* NativePageViewController.swift in Sources */,
 				D3C620AA2ED4B9A80009CCDA /* Theme.swift in Sources */,
 				A1B2C3D41E2F3A4B5C6D7E92 /* SharedWebViewController.swift in Sources */,

+ 1 - 0
ios/App/App/AppViewController.swift

@@ -15,6 +15,7 @@ import UIKit
         bridge?.registerPluginInstance(NativeTopBarPlugin())
         bridge?.registerPluginInstance(LiquidTabsPlugin())
         bridge?.registerPluginInstance(NativeBottomSheetPlugin())
+        bridge?.registerPluginInstance(NativeSelectionActionBarPlugin())
     }
 
     public override func viewDidLoad() {

+ 329 - 0
ios/App/App/NativeSelectionActionBarPlugin.swift

@@ -0,0 +1,329 @@
+import Capacitor
+import UIKit
+
+// MARK: - Model for a single selection action coming from JS
+
+private struct NativeSelectionAction {
+    let id: String
+    let title: String
+    let systemIcon: String?
+
+    init?(jsObject: JSObject) {
+        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
+    }
+}
+
+// MARK: - Native selection action bar UI
+
+private class NativeSelectionActionBarView: UIView {
+    /// Callback when an action is tapped. Sends the action id.
+    var onActionTapped: ((String) -> Void)?
+
+    /// Blurred background container.
+    private let blurView: UIVisualEffectView = {
+        let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.layer.cornerRadius = 16
+        view.clipsToBounds = true
+        view.isUserInteractionEnabled = true // ensure the blur container receives touch events
+        return view
+    }()
+
+    /// Horizontal stack that holds all action buttons.
+    private let stackView: 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
+        return stack
+    }()
+
+    // MARK: - Init
+
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        setupView()
+    }
+
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        setupView()
+    }
+
+    // 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?) {
+        configure(actions: actions, tintColor: tintColor, backgroundColor: backgroundColor)
+        attachIfNeeded(to: host)
+        animateInIfNeeded()
+    }
+
+    /// 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
+        })
+    }
+
+    // MARK: - Private helpers
+
+    /// Base visual setup: background, shadow, subview hierarchy and constraints.
+    private func setupView() {
+        backgroundColor = .clear
+        isUserInteractionEnabled = true // container must be interactive
+
+        // Shadow that appears around the blurred background.
+        layer.cornerRadius = 16
+        layer.masksToBounds = false
+        layer.shadowColor = UIColor.black.cgColor
+        layer.shadowOpacity = 0.12
+        layer.shadowOffset = CGSize(width: 0, height: 6)
+        layer.shadowRadius = 14
+
+        addSubview(blurView)
+        NSLayoutConstraint.activate([
+            blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
+            blurView.topAnchor.constraint(equalTo: topAnchor),
+            blurView.bottomAnchor.constraint(equalTo: bottomAnchor)
+        ])
+
+        blurView.contentView.addSubview(stackView)
+        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)
+        ])
+    }
+
+    /// 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() }
+
+        let tint = tintColor ?? .label
+        // Background color behind the blur. This helps match the Logseq background.
+        blurView.backgroundColor = backgroundColor ?? UIColor.logseqBackground.withAlphaComponent(0.94)
+
+        actions.forEach { action in
+            let button = makeButton(for: action, tintColor: tint)
+            stackView.addArrangedSubview(button)
+        }
+    }
+
+    /// Attaches the bar to the given host view, pinned to the bottom with safe area.
+    private func attachIfNeeded(to host: UIView) {
+        guard superview !== host else { return }
+        removeFromSuperview()
+
+        host.addSubview(self)
+        host.bringSubviewToFront(self) // ensure this bar is above other subviews (e.g. WKWebView)
+        translatesAutoresizingMaskIntoConstraints = false
+
+        NSLayoutConstraint.activate([
+            leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 12),
+            trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -12),
+            bottomAnchor.constraint(equalTo: host.safeAreaLayoutGuide.bottomAnchor, constant: -12)
+        ])
+    }
+
+    /// 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
+        })
+    }
+
+    /// Creates a single button for an action (icon + label in a vertical stack).
+    private func makeButton(for action: NativeSelectionAction, tintColor: UIColor) -> UIControl {
+        let control = UIControl()
+        control.accessibilityIdentifier = action.id
+        control.translatesAutoresizingMaskIntoConstraints = false
+        control.isUserInteractionEnabled = true
+
+        // Icon
+        let iconView = UIImageView()
+        iconView.contentMode = .scaleAspectFit
+        iconView.tintColor = tintColor
+        iconView.image = UIImage(systemName: action.systemIcon ?? "circle")
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+        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
+        label.font = UIFont.systemFont(ofSize: 12, weight: .semibold)
+        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
+
+        control.addSubview(column)
+        NSLayoutConstraint.activate([
+            column.leadingAnchor.constraint(equalTo: control.leadingAnchor),
+            column.trailingAnchor.constraint(equalTo: control.trailingAnchor),
+            column.topAnchor.constraint(equalTo: control.topAnchor, constant: 4),
+            column.bottomAnchor.constraint(equalTo: control.bottomAnchor, constant: -4)
+        ])
+
+        // Add targets for tap handling.
+        control.addTarget(self, action: #selector(handleTap(_:)), for: .touchUpInside)
+
+        return control
+    }
+
+    // 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)
+    }
+}
+
+// MARK: - Capacitor plugin
+
+@objc(NativeSelectionActionBarPlugin)
+public class NativeSelectionActionBarPlugin: CAPPlugin, CAPBridgedPlugin {
+    public let identifier = "NativeSelectionActionBarPlugin"
+    public let jsName = "NativeSelectionActionBarPlugin"
+    public let pluginMethods: [CAPPluginMethod] = [
+        CAPPluginMethod(name: "present", returnType: CAPPluginReturnPromise),
+        CAPPluginMethod(name: "dismiss", returnType: CAPPluginReturnPromise)
+    ]
+
+    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:))
+        let tintColor = call.getString("tintColor")?.toUIColor(defaultColor: .label)
+        let backgroundColor = call.getString("backgroundColor")?.toUIColor(
+            defaultColor: UIColor.logseqBackground.withAlphaComponent(0.94)
+        )
+
+        DispatchQueue.main.async {
+            guard let host = self.hostView() else {
+                call.reject("Host view not found")
+                return
+            }
+
+            // If actions are empty, hide the bar instead.
+            guard !actions.isEmpty else {
+                self.actionBar?.dismiss()
+                self.actionBar = nil
+                call.resolve()
+                return
+            }
+
+            let bar = self.actionBar ?? NativeSelectionActionBarView()
+            bar.onActionTapped = { [weak self] id in
+                print("action id", id)
+                self?.notifyListeners("action", data: ["id": id])
+            }
+            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()
+            self.actionBar = nil
+            call.resolve()
+        }
+    }
+
+    /// 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
+        }
+        return bridge?.viewController?.view
+    }
+}
+
+// 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("#") {
+            hexString.removeFirst()
+        }
+
+        var rgbValue: UInt64 = 0
+        guard Scanner(string: hexString).scanHexInt64(&rgbValue) else {
+            return defaultColor
+        }
+
+        switch hexString.count {
+        case 6: // RRGGBB
+            return UIColor(
+                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
+            return UIColor(
+                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
+            )
+        default:
+            return defaultColor
+        }
+    }
+}

+ 2 - 0
src/main/frontend/mobile/util.cljs

@@ -25,10 +25,12 @@
 (defonce ui-local (registerPlugin "UILocal"))
 (defonce native-top-bar nil)
 (defonce native-bottom-sheet nil)
+(defonce native-selection-action-bar nil)
 (defonce ios-utils nil)
 (when (native-ios?)
   (set! native-top-bar (registerPlugin "NativeTopBarPlugin"))
   (set! native-bottom-sheet (registerPlugin "NativeBottomSheetPlugin"))
+  (set! native-selection-action-bar (registerPlugin "NativeSelectionActionBarPlugin"))
   (set! ios-utils (registerPlugin "Utils")))
 
 (defn hide-splash []

+ 93 - 34
src/main/mobile/components/selection_toolbar.cljs

@@ -2,42 +2,101 @@
   "Selection action bar, activated when swipe on a block"
   (:require [frontend.db :as db]
             [frontend.handler.editor :as editor-handler]
+            [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
-            [frontend.ui :as ui]
             [frontend.util.url :as url-util]
+            [logseq.shui.hooks :as hooks]
             [rum.core :as rum]))
 
-(defn- action-command
-  [icon description command-handler]
-  (let [callback
-        (fn []
-          (state/set-state! :mobile/show-action-bar? false)
-          (editor-handler/clear-selection!))]
-    [:button.bottom-action.flex-row
-     {:on-click (fn [_event]
-                  (command-handler)
-                  (callback))}
-     (ui/icon icon {:style {:fontSize 23}})
-     [:div.description description]]))
+(defn- dismiss-action-bar!
+  []
+  (.dismiss ^js mobile-util/native-selection-action-bar))
 
-(rum/defcs action-bar < rum/reactive
-  [state]
-  (let [blocks (->> (state/get-selection-block-ids)
-                    (keep (fn [id]
-                            (db/entity [:block/uuid id]))))
-        block-ids (map :block/uuid blocks)]
-    [:div.action-bar
-     [:div.action-bar-commands
-      (action-command "copy" "Copy" #(editor-handler/copy-selection-blocks false))
-      (action-command "cut" "Delete" #(editor-handler/cut-selection-blocks false {:mobile-action-bar? true}))
-      (action-command "registered" "Copy ref"
-                      (fn [_event] (editor-handler/copy-block-refs)))
-      (action-command "link" "Copy url"
-                      (fn [_event] (let [current-repo (state/get-current-repo)
-                                         tap-f (fn [block-id]
-                                                 (url-util/get-logseq-graph-uuid-url nil current-repo block-id))]
-                                     (editor-handler/copy-block-ref! (first block-ids) tap-f))))
-      (action-command "x" "Unselect"
-                      (fn [_event]
-                        (state/clear-selection!)
-                        (state/set-state! :mobile/show-action-bar? false)))]]))
+(defn close-selection-bar!
+  []
+  (dismiss-action-bar!)
+  (state/set-state! :mobile/show-action-bar? false)
+  (editor-handler/clear-selection!))
+
+(defn- selected-block-ids
+  []
+  (->> (state/get-selection-block-ids)
+       (keep (fn [id]
+               (some-> (db/entity [:block/uuid id])
+                       :block/uuid)))))
+
+(defn- selection-actions
+  []
+  (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 "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 "number.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)
+                       tap-f (fn [block-id]
+                               (url-util/get-logseq-graph-uuid-url nil current-repo block-id))]
+                   (when-let [block-id (first (selected-block-ids))]
+                     (editor-handler/copy-block-ref! block-id tap-f)))
+                 (close!))}
+     {:id "unselect"
+      :label "Unselect"
+      :icon "x"
+      :system-icon "xmark"
+      :handler (fn []
+                 (state/clear-selection!)
+                 (close!))}]))
+
+(rum/defc action-bar
+  []
+  (let [actions (selection-actions)
+        handlers-ref (hooks/use-ref nil)]
+    (set! (.-current handlers-ref) (into {} (map (juxt :id :handler) actions)))
+
+    (hooks/use-effect!
+     (fn []
+       (when (and (mobile-util/native-ios?)
+                  mobile-util/native-selection-action-bar)
+         (let [listener (.addListener ^js mobile-util/native-selection-action-bar
+                                      "action"
+                                      (fn [^js e]
+                                        (when-let [id (.-id e)]
+                                          (prn :debug :id id
+                                               :handler (.-current handlers-ref))
+                                          (when-let [handler (get (.-current handlers-ref) id)]
+                                            (handler)))))
+               actions' {:actions (map (fn [{:keys [id label system-icon]}]
+                                         {:id id
+                                          :title label
+                                          :systemIcon system-icon})
+                                       actions)}]
+           (.present ^js mobile-util/native-selection-action-bar (clj->js actions'))
+           (fn []
+             (dismiss-action-bar!)
+             (cond
+               (and listener (.-remove listener)) ((.-remove listener))
+               listener (.then listener (fn [^js handle] (.remove handle))))))))
+     [])
+
+    [:<>]))

+ 2 - 0
src/main/mobile/core.cljs

@@ -13,6 +13,7 @@
             [lambdaisland.glogi :as log]
             [logseq.shui.ui :as shui]
             [mobile.components.app :as app]
+            [mobile.components.selection-toolbar :as selection-toolbar]
             [mobile.events]
             [mobile.init :as init]
             [mobile.navigation :as mobile-nav]
@@ -36,6 +37,7 @@
      (fn [route]
        (when (state/get-edit-block)
          (state/clear-edit!))
+       (selection-toolbar/close-selection-bar!)
        (let [route-name (get-in route [:data :name])
              path (-> js/location .-hash (string/replace-first #"^#" ""))
              pop? (= :pop @mobile-nav/navigation-source)

+ 1 - 0
src/main/mobile/routes.cljs

@@ -1,4 +1,5 @@
 (ns mobile.routes
+  "Routes used in mobile app"
   (:require [frontend.components.page :as page]))
 
 (def routes