Files
logseq/ios/App/App/NativeEditorToolbarPlugin.swift
2025-12-10 21:23:44 +08:00

475 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Capacitor
import UIKit
private struct NativeEditorAction {
let id: String
let title: String
let systemIcon: String?
init?(jsObject: JSObject) {
guard let id = jsObject["id"] as? String else { return nil }
self.id = id
self.title = (jsObject["title"] as? String) ?? id
self.systemIcon = jsObject["systemIcon"] as? String
}
}
private class NativeEditorToolbarView: UIView {
/// Callback when any action is tapped.
var onActionTapped: ((String) -> Void)?
/// Used to prevent an old dismiss animation from removing a newly-presented bar.
private var dismissGeneration: Int = 0
/// Store actions so we can reconfigure when theme (light/dark) changes.
private var storedActions: [NativeEditorAction] = []
private var storedTrailingAction: NativeEditorAction?
private let blurView: UIVisualEffectView = {
let effect = UIBlurEffect(style: .systemChromeMaterial)
let view = UIVisualEffectView(effect: effect)
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 18
view.clipsToBounds = true
view.isUserInteractionEnabled = true
return view
}()
private let rootStack: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 6
stack.isLayoutMarginsRelativeArrangement = true
stack.layoutMargins = UIEdgeInsets(top: 7, left: 10, bottom: 7, right: 10)
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private let actionsScrollView: UIScrollView = {
let view = UIScrollView()
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.alwaysBounceHorizontal = true
view.contentInsetAdjustmentBehavior = .never
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private let actionsStack: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
stack.isLayoutMarginsRelativeArrangement = true
stack.layoutMargins = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2)
return stack
}()
private let trailingContainer: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 6
stack.translatesAutoresizingMaskIntoConstraints = false
stack.isUserInteractionEnabled = true
return stack
}()
private let separator: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = UIColor.label.withAlphaComponent(0.08)
view.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
view.heightAnchor.constraint(greaterThanOrEqualToConstant: 20).isActive = true
return view
}()
private let trailingButton: UIButton = {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.widthAnchor.constraint(greaterThanOrEqualToConstant: 30).isActive = true
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 30).isActive = true
return button
}()
private var trailingActionId: String?
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
}
// MARK: - Public
func present(on host: UIView,
actions: [NativeEditorAction],
trailingAction: NativeEditorAction?,
tintColor: UIColor?,
backgroundColor: UIColor?) {
// Bump generation to invalidate any previous dismiss completion
dismissGeneration += 1
layer.removeAllAnimations()
// Store actions so we can re-apply them when theme changes
storedActions = actions
storedTrailingAction = trailingAction
// We ignore tintColor/backgroundColor theyre driven by theme.
configure(actions: actions,
trailingAction: trailingAction)
attachIfNeeded(to: host)
animateIn()
}
func dismiss(animated: Bool = true) {
dismissGeneration += 1
let currentGen = dismissGeneration
layer.removeAllAnimations()
guard animated else {
removeFromSuperview()
transform = .identity
alpha = 1
return
}
UIView.animate(withDuration: 0.16,
delay: 0,
options: [.curveEaseIn, .allowUserInteraction],
animations: {
self.alpha = 0
self.transform = CGAffineTransform(translationX: 0, y: 8)
}, completion: { _ in
// Only remove if no newer present/dismiss has happened.
if currentGen == self.dismissGeneration {
self.removeFromSuperview()
self.transform = .identity
self.alpha = 1
}
})
}
// MARK: - Theme / trait changes
/// Returns the theme-appropriate tint (light: black, dark: white).
private func currentTintColor() -> UIColor {
return traitCollection.userInterfaceStyle == .dark ? .white : .black
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else {
return
}
// Reconfigure with new tint when light/dark changes
configure(actions: storedActions, trailingAction: storedTrailingAction)
}
// MARK: - Private helpers
private func setupView() {
backgroundColor = .clear
isOpaque = false
layer.cornerRadius = 18
layer.masksToBounds = false
layer.shadowColor = UIColor.black.withAlphaComponent(0.25).cgColor
layer.shadowOpacity = 0.25
layer.shadowOffset = CGSize(width: 0, height: 8)
layer.shadowRadius = 22
layer.borderColor = UIColor.label.withAlphaComponent(0.04).cgColor
layer.borderWidth = 0.5
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(rootStack)
NSLayoutConstraint.activate([
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)
])
trailingContainer.isHidden = true
trailingContainer.addArrangedSubview(separator)
trailingContainer.addArrangedSubview(trailingButton)
trailingButton.addTarget(self, action: #selector(handleTrailingTap(_:)), for: .touchUpInside)
}
private func configure(actions: [NativeEditorAction],
trailingAction: NativeEditorAction?) {
let tint = currentTintColor()
// Always use Logseq background (theme-aware)
let bgBase = UIColor.logseqBackground
blurView.backgroundColor = bgBase.withAlphaComponent(0.9)
blurView.contentView.backgroundColor = .clear
// Main actions
actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
actions.forEach { action in
let button = makeButton(for: action, tintColor: tint)
actionsStack.addArrangedSubview(button)
}
// Trailing action (keyboard hide or audio)
if let trailingAction {
trailingContainer.isHidden = false
trailingActionId = trailingAction.id
separator.isHidden = false
var config = UIButton.Configuration.plain()
config.baseForegroundColor = tint
config.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
config.preferredSymbolConfigurationForImage =
UIImage.SymbolConfiguration(pointSize: 17, weight: .regular)
config.background = .clear()
let trailingSymbol = trailingAction.systemIcon ?? "keyboard.chevron.compact.down"
config.image = UIImage(systemName: trailingSymbol) ?? UIImage(systemName: "keyboard.chevron.compact.down")
trailingButton.configuration = config
trailingButton.tintColor = tint
trailingButton.accessibilityIdentifier = trailingAction.id
trailingButton.configurationUpdateHandler = { button in
var cfg = button.configuration
if button.isHighlighted {
cfg?.background.backgroundColor = tint.withAlphaComponent(0.18)
} else {
cfg?.background.backgroundColor = UIColor.clear
}
button.configuration = cfg
}
} else {
trailingContainer.isHidden = true
trailingActionId = nil
separator.isHidden = true
trailingButton.configuration = nil
trailingButton.configurationUpdateHandler = nil
}
}
private func attachIfNeeded(to host: UIView) {
if superview !== host {
removeFromSuperview()
host.addSubview(self)
host.bringSubviewToFront(self)
translatesAutoresizingMaskIntoConstraints = false
let leading = leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 16)
let trailing = trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -16)
let bottom: NSLayoutConstraint
if #available(iOS 15.0, *) {
bottom = bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -10)
} else {
bottom = bottomAnchor.constraint(equalTo: host.safeAreaLayoutGuide.bottomAnchor, constant: -16)
}
NSLayoutConstraint.activate([leading, trailing, bottom])
}
}
private func animateIn() {
alpha = 0
transform = CGAffineTransform(translationX: 0, y: 10)
UIView.animate(withDuration: 0.24,
delay: 0,
usingSpringWithDamping: 0.86,
initialSpringVelocity: 0.4,
options: [.curveEaseOut, .allowUserInteraction],
animations: {
self.alpha = 1
self.transform = .identity
}, completion: nil)
}
private func makeButton(for action: NativeEditorAction, tintColor: UIColor) -> UIButton {
var config = UIButton.Configuration.plain()
config.baseForegroundColor = tintColor
config.title = nil
config.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 6, bottom: 4, trailing: 6)
config.preferredSymbolConfigurationForImage =
UIImage.SymbolConfiguration(pointSize: 17, weight: .regular)
config.background = .clear()
let button = UIButton(configuration: config, primaryAction: nil)
button.tintColor = tintColor
let symbolName = action.systemIcon ?? "circle"
// Try custom SF Symbol as systemName first, then fall back to asset by name.
let image =
UIImage(systemName: symbolName) ??
UIImage(named: symbolName) ??
UIImage(systemName: "circle")
button.setImage(image, for: .normal)
button.accessibilityIdentifier = action.id
button.widthAnchor.constraint(greaterThanOrEqualToConstant: 30).isActive = true
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 30).isActive = true
button.configurationUpdateHandler = { btn in
var cfg = btn.configuration
if btn.isHighlighted {
cfg?.background.backgroundColor = tintColor.withAlphaComponent(0.18)
} else {
cfg?.background.backgroundColor = .clear
}
btn.configuration = cfg
}
button.addTarget(self, action: #selector(handleActionTap(_:)), for: .touchUpInside)
return button
}
@objc private func handleActionTap(_ sender: UIButton) {
guard let id = sender.accessibilityIdentifier else { return }
onActionTapped?(id)
}
@objc private func handleTrailingTap(_ sender: UIButton) {
guard let id = trailingActionId ?? sender.accessibilityIdentifier else { return }
onActionTapped?(id)
}
}
@objc(NativeEditorToolbarPlugin)
public class NativeEditorToolbarPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "NativeEditorToolbarPlugin"
public let jsName = "NativeEditorToolbarPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "present", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "dismiss", returnType: CAPPluginReturnPromise)
]
private var toolbar: NativeEditorToolbarView?
@objc func present(_ call: CAPPluginCall) {
let rawActions = call.getArray("actions", JSObject.self) ?? []
let actions = rawActions.compactMap(NativeEditorAction.init(jsObject:))
let trailingAction = call.getObject("trailingAction").flatMap(NativeEditorAction.init(jsObject:))
// We still read tintColor/backgroundColor for future flexibility,
// but the toolbar currently ignores them and uses theme colors.
_ = call.getString("tintColor")
_ = call.getString("backgroundColor")
DispatchQueue.main.async {
guard let host = self.hostView() else {
call.reject("Host view not found")
return
}
// If there are no actions and no trailing action, dismiss and clear toolbar
guard !actions.isEmpty || trailingAction != nil else {
self.toolbar?.dismiss(animated: true)
self.toolbar = nil
call.resolve()
return
}
let bar = self.toolbar ?? NativeEditorToolbarView()
bar.onActionTapped = { [weak self] id in
guard let self = self else { return }
self.notifyListeners("action", data: ["id": id])
}
bar.present(on: host,
actions: actions,
trailingAction: trailingAction,
tintColor: nil,
backgroundColor: nil)
self.toolbar = bar
call.resolve()
}
}
@objc func dismiss(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.toolbar?.dismiss(animated: true)
self.toolbar = nil
call.resolve()
}
}
private func hostView() -> UIView? {
if let parent = bridge?.viewController?.parent?.view {
return parent
}
return bridge?.viewController?.view
}
}
// MARK: - Color helper
private extension String {
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
}
}
}