enhance: native selection bar

This commit is contained in:
Tienson Qin
2025-11-26 22:53:20 +08:00
parent 38baa56286
commit ec32fe3590
7 changed files with 432 additions and 34 deletions

View File

@@ -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 */,

View File

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

View File

@@ -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
}
}
}

View File

@@ -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 []

View File

@@ -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))))))))
[])
[:<>]))

View File

@@ -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)

View File

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