Browse Source

enhance(mobile): add indent && outdent to selection bar

Also, make the selection bar scrollable
Tienson Qin 1 day ago
parent
commit
da26cbebe1

+ 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

+ 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 "indent"
+      :label "Indent"
+      :system-icon "arrow.right"
+      :handler (fn []
+                 (editor-handler/on-tab :right))}
+     {:id "outdent"
+      :label "Outdent"
+      :system-icon "arrow.left"
+      :handler (fn []
+                 (editor-handler/on-tab :left))}
      {: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!)