mirror of
https://github.com/logseq/logseq.git
synced 2026-02-01 22:47:36 +00:00
@@ -2,6 +2,7 @@ apply plugin: 'com.android.application'
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||
|
||||
android {
|
||||
namespace "com.logseq.app"
|
||||
@@ -26,6 +27,14 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion rootProject.ext.composeCompilerVersion
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '21'
|
||||
}
|
||||
@@ -42,6 +51,16 @@ dependencies {
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation platform("androidx.compose:compose-bom:$composeBomVersion")
|
||||
implementation "androidx.activity:activity-compose:$androidxActivityVersion"
|
||||
implementation "androidx.compose.ui:ui"
|
||||
implementation "androidx.compose.foundation:foundation"
|
||||
implementation "androidx.compose.material3:material3"
|
||||
implementation "androidx.compose.material:material-icons-extended"
|
||||
implementation "androidx.compose.runtime:runtime"
|
||||
implementation "androidx.compose.animation:animation"
|
||||
implementation "androidx.navigation:navigation-compose:$androidxNavigationVersion"
|
||||
implementation "com.google.android.material:material:$materialVersion"
|
||||
implementation project(':capacitor-android')
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
android:name="com.logseq.app.MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustNothing"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:label="@string/title_activity_main"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch">
|
||||
|
||||
302
android/app/src/main/java/com/logseq/app/ComposeHost.kt
Normal file
302
android/app/src/main/java/com/logseq/app/ComposeHost.kt
Normal file
@@ -0,0 +1,302 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.app.Activity
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val ROOT_ROUTE = "web/{encodedPath}"
|
||||
|
||||
data class NavigationEvent(
|
||||
val navigationType: String,
|
||||
val path: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Hosts the existing WebView inside Compose and drives Compose Navigation
|
||||
* so we get back gestures/animations while delegating actual routing to the JS layer.
|
||||
*/
|
||||
object ComposeHost {
|
||||
private val navEvents = MutableSharedFlow<NavigationEvent>(extraBufferCapacity = 64)
|
||||
|
||||
fun applyNavigation(navigationType: String?, path: String?) {
|
||||
val type = (navigationType ?: "push").lowercase()
|
||||
val safePath = path?.takeIf { it.isNotBlank() } ?: "/"
|
||||
navEvents.tryEmit(NavigationEvent(type, safePath))
|
||||
}
|
||||
|
||||
fun renderWithSystemInsets(
|
||||
activity: Activity,
|
||||
webView: WebView,
|
||||
onBackRequested: () -> Unit,
|
||||
onExit: () -> Unit = { activity.finish() }
|
||||
) {
|
||||
WebViewSnapshotManager.registerWindow(activity.window)
|
||||
val root = activity.findViewById<FrameLayout>(android.R.id.content)
|
||||
|
||||
// WebView already created by BridgeActivity; just reparent it into Compose.
|
||||
(webView.parent as? ViewGroup)?.removeView(webView)
|
||||
|
||||
val composeView = ComposeView(activity).apply {
|
||||
tag = "compose-host-webview"
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
ComposeNavigationHost(
|
||||
navEvents = navEvents,
|
||||
webView = webView,
|
||||
onBackRequested = onBackRequested,
|
||||
onExit = onExit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (root.findViewWithTag<ComposeView>("compose-host-webview") == null) {
|
||||
root.addView(
|
||||
composeView,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodePath(path: String): String =
|
||||
Uri.encode(if (path.isBlank()) "/" else path)
|
||||
|
||||
private fun routeFor(path: String): String =
|
||||
"web/${encodePath(path)}"
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
private fun ComposeNavigationHost(
|
||||
navEvents: SharedFlow<NavigationEvent>,
|
||||
webView: WebView,
|
||||
onBackRequested: () -> Unit,
|
||||
onExit: () -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Track the last navigation type so we can change slide direction.
|
||||
val lastNavTypeState = remember { mutableStateOf("push") }
|
||||
|
||||
HandleNavigationEvents(
|
||||
navController = navController,
|
||||
navEvents = navEvents,
|
||||
webView = webView
|
||||
) { type ->
|
||||
lastNavTypeState.value = type
|
||||
}
|
||||
|
||||
// You can comment this out if you want to rely purely on JS for back.
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = ROOT_ROUTE,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||
) {
|
||||
composable(
|
||||
route = ROOT_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument("encodedPath") {
|
||||
defaultValue = encodePath("/")
|
||||
}
|
||||
),
|
||||
// ---- PUSH: A -> B ----
|
||||
enterTransition = {
|
||||
if (lastNavTypeState.value == "pop") {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { fullWidth -> -fullWidth / 3 },
|
||||
animationSpec = tween(220)
|
||||
) + fadeIn(animationSpec = tween(180))
|
||||
} else {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { fullWidth -> fullWidth },
|
||||
animationSpec = tween(220)
|
||||
) + fadeIn(animationSpec = tween(180))
|
||||
}
|
||||
},
|
||||
exitTransition = {
|
||||
if (lastNavTypeState.value == "pop") {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { fullWidth -> fullWidth },
|
||||
animationSpec = tween(200)
|
||||
) + fadeOut(animationSpec = tween(160))
|
||||
} else {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { fullWidth -> -fullWidth / 4 },
|
||||
animationSpec = tween(220)
|
||||
) + fadeOut(animationSpec = tween(180))
|
||||
}
|
||||
},
|
||||
// ---- POP: B -> A ----
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { fullWidth -> -fullWidth / 4 },
|
||||
animationSpec = tween(200)
|
||||
) + fadeIn(animationSpec = tween(160))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { fullWidth -> fullWidth },
|
||||
animationSpec = tween(200)
|
||||
) + fadeOut(animationSpec = tween(160))
|
||||
}
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
FrameLayout(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
val webContainer = FrameLayout(context).apply {
|
||||
id = R.id.webview_container
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
val overlayContainer = FrameLayout(context).apply {
|
||||
id = R.id.webview_overlay_container
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
isClickable = false
|
||||
isFocusable = false
|
||||
}
|
||||
|
||||
overlayContainer.setBackgroundColor(Color.TRANSPARENT)
|
||||
overlayContainer.alpha = 1f
|
||||
overlayContainer.visibility = android.view.View.GONE
|
||||
|
||||
addView(webContainer)
|
||||
addView(overlayContainer)
|
||||
|
||||
WebViewSnapshotManager.registerOverlay(overlayContainer)
|
||||
|
||||
(webView.parent as? ViewGroup)?.removeView(webView)
|
||||
webContainer.addView(
|
||||
webView,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { root ->
|
||||
val webContainer =
|
||||
root.findViewById<FrameLayout>(R.id.webview_container)
|
||||
val overlayContainer =
|
||||
root.findViewById<FrameLayout>(R.id.webview_overlay_container)
|
||||
WebViewSnapshotManager.registerOverlay(overlayContainer)
|
||||
if (webView.parent !== webContainer) {
|
||||
(webView.parent as? ViewGroup)?.removeView(webView)
|
||||
webContainer.addView(
|
||||
webView,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandleNavigationEvents(
|
||||
navController: NavHostController,
|
||||
navEvents: SharedFlow<NavigationEvent>,
|
||||
webView: WebView,
|
||||
onNavType: (String) -> Unit
|
||||
) {
|
||||
LaunchedEffect(navController) {
|
||||
var snapshotVersion = 0
|
||||
navEvents.collect { event ->
|
||||
snapshotVersion += 1
|
||||
val currentSnapshotVersion = snapshotVersion
|
||||
WebViewSnapshotManager.showSnapshot("navigation", webView)
|
||||
onNavType(event.navigationType)
|
||||
val route = routeFor(event.path)
|
||||
when (event.navigationType) {
|
||||
"push" -> navController.navigate(route)
|
||||
|
||||
"replace" -> {
|
||||
navController.popBackStack()
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
"pop" -> {
|
||||
if (!navController.popBackStack()) {
|
||||
// Already at root; nothing to pop.
|
||||
}
|
||||
}
|
||||
|
||||
"reset" -> {
|
||||
navController.popBackStack(route = ROOT_ROUTE, inclusive = false)
|
||||
navController.navigate(route) {
|
||||
popUpTo(ROOT_ROUTE) {
|
||||
inclusive = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
else -> navController.navigate(route)
|
||||
}
|
||||
|
||||
launch {
|
||||
delay(260)
|
||||
if (currentSnapshotVersion == snapshotVersion) {
|
||||
WebViewSnapshotManager.clearSnapshot("navigation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
534
android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt
Normal file
534
android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt
Normal file
@@ -0,0 +1,534 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.unit.dp // New Import for DP units
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
// NOTE: NativeUiUtils and MaterialIconResolver are assumed to be defined elsewhere in your project
|
||||
// and are necessary for this code to compile.
|
||||
|
||||
@CapacitorPlugin(name = "LiquidTabsPlugin")
|
||||
class LiquidTabsPlugin : Plugin() {
|
||||
private var bottomNav: ComposeView? = null
|
||||
private var searchContainer: LinearLayout? = null
|
||||
private var searchInput: EditText? = null
|
||||
private var resultsContainer: LinearLayout? = null
|
||||
private var closeButton: TextView? = null
|
||||
private var originalBottomPadding: Int? = null
|
||||
|
||||
private var tabsState by mutableStateOf<List<TabSpec>>(emptyList())
|
||||
private var currentTabId by mutableStateOf<String?>(null)
|
||||
|
||||
// Define a standard horizontal padding for consistency
|
||||
private val HORIZONTAL_PADDING_DP = 16f
|
||||
private val VERTICAL_PADDING_DP = 12f
|
||||
private val RESULT_ROW_VERTICAL_PADDING_DP = 10f
|
||||
|
||||
// 💡 NEW: Define padding for the Tab Bar edges (makes it compact and adds left/right space)
|
||||
private val TAB_BAR_HORIZONTAL_PADDING = 12.dp
|
||||
private val ACCENT_COLOR_HEX = "#6097c7"
|
||||
|
||||
@PluginMethod
|
||||
fun configureTabs(call: PluginCall) {
|
||||
val activity = activity ?: run {
|
||||
call.reject("No activity")
|
||||
return
|
||||
}
|
||||
val tabs = parseTabs(call.getArray("tabs"))
|
||||
|
||||
activity.runOnUiThread {
|
||||
tabsState = tabs
|
||||
val activeId = currentTabId?.takeIf { id -> tabs.any { it.id == id } }
|
||||
?: tabs.firstOrNull()?.id
|
||||
currentTabId = activeId
|
||||
ensureNav()
|
||||
currentTabId?.let { id ->
|
||||
tabsState.find { it.id == id }?.let { tab ->
|
||||
handleSelection(tab, reselected = false)
|
||||
}
|
||||
} ?: hideSearchUi()
|
||||
adjustWebViewPadding()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun selectTab(call: PluginCall) {
|
||||
val id = call.getString("id") ?: run {
|
||||
call.reject("Missing id")
|
||||
return
|
||||
}
|
||||
val tab = tabsState.find { it.id == id }
|
||||
if (tab == null) {
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
val nav = bottomNav
|
||||
if (nav == null) {
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
nav.post {
|
||||
val reselected = currentTabId == tab.id
|
||||
handleSelection(tab, reselected)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateNativeSearchResults(call: PluginCall) {
|
||||
val results = parseResults(call.getArray("results"))
|
||||
activity?.runOnUiThread {
|
||||
ensureSearchUi()
|
||||
val container = resultsContainer ?: return@runOnUiThread
|
||||
container.removeAllViews()
|
||||
results.forEach { result ->
|
||||
container.addView(makeResultRow(result))
|
||||
}
|
||||
call.resolve()
|
||||
} ?: call.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX: Allows the web view to explicitly show the search UI again,
|
||||
* typically after backing out of an opened search result item.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun showSearchUiNative(call: PluginCall) {
|
||||
activity?.runOnUiThread {
|
||||
showSearchUi()
|
||||
// Ensure padding is correct when search UI is manually shown
|
||||
adjustWebViewPadding()
|
||||
call.resolve()
|
||||
} ?: call.resolve()
|
||||
}
|
||||
|
||||
|
||||
private fun ensureNav(): ComposeView {
|
||||
val activity = activity ?: throw IllegalStateException("No activity")
|
||||
val root = NativeUiUtils.contentRoot(activity)
|
||||
val nav = bottomNav ?: ComposeView(activity).also { view ->
|
||||
view.id = R.id.liquid_tabs_bottom_nav // Assuming R.id.liquid_tabs_bottom_nav is defined
|
||||
view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
view.layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM
|
||||
)
|
||||
view.setBackgroundColor(LogseqTheme.current().background)
|
||||
bottomNav = view
|
||||
root.addView(view)
|
||||
setupImeBehaviorForNav(view)
|
||||
}
|
||||
|
||||
nav.setContent {
|
||||
BottomNavBar(
|
||||
tabs = tabsState,
|
||||
currentId = currentTabId,
|
||||
onSelect = { tab ->
|
||||
val reselected = tab.id == currentTabId
|
||||
handleSelection(tab, reselected)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
nav.doOnNextLayout { adjustWebViewPadding() }
|
||||
return nav
|
||||
}
|
||||
|
||||
private fun handleSelection(tab: TabSpec, reselected: Boolean) {
|
||||
currentTabId = tab.id
|
||||
if (tab.role == "search") {
|
||||
showSearchUi()
|
||||
} else {
|
||||
hideSearchUi()
|
||||
}
|
||||
|
||||
notifyListeners("tabSelected", JSObject().put("id", tab.id).put("reselected", reselected))
|
||||
}
|
||||
|
||||
private fun adjustWebViewPadding() {
|
||||
val webView = bridge.webView ?: return
|
||||
val nav = bottomNav ?: return
|
||||
if (originalBottomPadding == null) {
|
||||
originalBottomPadding = webView.paddingBottom
|
||||
}
|
||||
nav.post {
|
||||
val padding = originalBottomPadding ?: 0
|
||||
val h = nav.height
|
||||
val newPadding = if (searchContainer?.visibility == View.VISIBLE) {
|
||||
padding
|
||||
} else {
|
||||
padding + h
|
||||
}
|
||||
webView.setPadding(webView.paddingLeft, webView.paddingTop, webView.paddingRight, newPadding)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupImeBehaviorForNav(nav: View) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(nav) { v, insets ->
|
||||
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
|
||||
val extra = if (imeVisible) {
|
||||
imeInsets.bottom
|
||||
} else {
|
||||
0
|
||||
}
|
||||
v.translationY = extra.toFloat()
|
||||
insets
|
||||
}
|
||||
|
||||
ViewCompat.requestApplyInsets(nav)
|
||||
}
|
||||
|
||||
private fun ensureSearchUi() {
|
||||
if (searchContainer != null) return
|
||||
showSearchUi()
|
||||
}
|
||||
|
||||
private fun showSearchUi() {
|
||||
val activity = activity ?: return
|
||||
val root = NativeUiUtils.contentRoot(activity)
|
||||
val theme = LogseqTheme.current()
|
||||
val labelColor = if (theme.isDark) theme.tint else Color.BLACK
|
||||
val secondaryLabelColor =
|
||||
if (theme.isDark) Color.argb(200, 245, 247, 250) else Color.DKGRAY
|
||||
|
||||
// Calculate status bar height for safe area padding
|
||||
val insets = ViewCompat.getRootWindowInsets(root)
|
||||
val statusBarHeight = insets?.getInsets(WindowInsetsCompat.Type.statusBars())?.top ?: 0
|
||||
|
||||
val container = searchContainer ?: LinearLayout(activity).also { layout ->
|
||||
layout.orientation = LinearLayout.VERTICAL
|
||||
layout.setBackgroundColor(theme.background)
|
||||
|
||||
val lp = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
// Set bottom margin to clear the bottom navigation bar
|
||||
lp.setMargins(0, 0, 0, bottomNav?.height ?: NativeUiUtils.dp(activity, 56f))
|
||||
|
||||
// Remove elevation/shadow
|
||||
layout.elevation = 0f
|
||||
|
||||
// Apply status bar height as top padding for safe area
|
||||
layout.setPadding(0, statusBarHeight, 0, 0)
|
||||
|
||||
root.addView(layout, lp)
|
||||
searchContainer = layout
|
||||
}
|
||||
|
||||
// Re-apply top padding in case insets were not available on first run
|
||||
container.setPadding(0, statusBarHeight, 0, 0)
|
||||
|
||||
// Search Input Setup
|
||||
if (searchInput == null) {
|
||||
// Container for input and close button
|
||||
val searchRow = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL // Center items vertically
|
||||
}
|
||||
|
||||
val input = EditText(activity).apply {
|
||||
hint = "Search"
|
||||
setSingleLine(true)
|
||||
setTextColor(labelColor)
|
||||
setHintTextColor(secondaryLabelColor)
|
||||
// Remove EditText default background/border for a flat look
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
// Fine-tune padding inside the EditText for text alignment
|
||||
setPadding(
|
||||
NativeUiUtils.dp(activity, 0f),
|
||||
NativeUiUtils.dp(activity, 10f),
|
||||
NativeUiUtils.dp(activity, 0f),
|
||||
NativeUiUtils.dp(activity, 10f)
|
||||
)
|
||||
|
||||
// Layout params to make EditText take most of the horizontal space
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.0f)
|
||||
|
||||
setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DEL -> notifyListeners("keyboardHackKey", JSObject().put("key", "backspace"))
|
||||
KeyEvent.KEYCODE_ENTER -> notifyListeners("keyboardHackKey", JSObject().put("key", "enter"))
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
// Toggle close button visibility based on text
|
||||
val hasText = !s.isNullOrEmpty()
|
||||
closeButton?.visibility = if (hasText) View.VISIBLE else View.GONE
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
notifyListeners("searchChanged", JSObject().put("query", s?.toString() ?: ""))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Close Button
|
||||
val button = TextView(activity).apply {
|
||||
text = "X" // Close icon (using simple 'X')
|
||||
setTextColor(secondaryLabelColor)
|
||||
textSize = 18f
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(
|
||||
NativeUiUtils.dp(activity, 8f),
|
||||
NativeUiUtils.dp(activity, 8f),
|
||||
NativeUiUtils.dp(activity, 8f),
|
||||
NativeUiUtils.dp(activity, 8f)
|
||||
)
|
||||
visibility = View.GONE // Initially hidden
|
||||
|
||||
setOnClickListener {
|
||||
input.setText("") // Clear the EditText
|
||||
// TextWatcher will handle notifying the web view and hiding the button
|
||||
}
|
||||
// Set layout params for the button
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
// 1. Add EditText
|
||||
searchRow.addView(input)
|
||||
// 2. Add Close Button
|
||||
searchRow.addView(button)
|
||||
|
||||
// inputContainer (was the old search container wrapper)
|
||||
val inputContainer = LinearLayout(activity).apply {
|
||||
// Add horizontal padding for the search box container
|
||||
setPadding(
|
||||
NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP),
|
||||
NativeUiUtils.dp(activity, VERTICAL_PADDING_DP),
|
||||
NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP),
|
||||
NativeUiUtils.dp(activity, VERTICAL_PADDING_DP)
|
||||
)
|
||||
orientation = LinearLayout.VERTICAL
|
||||
// Add the new searchRow (input + button)
|
||||
addView(searchRow, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
|
||||
// Add a divider below the search box (optional visual polish)
|
||||
val divider = View(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, NativeUiUtils.dp(activity, 1f))
|
||||
setBackgroundColor(
|
||||
if (theme.isDark) Color.argb(40, 245, 247, 250) else Color.parseColor("#E0E0E0")
|
||||
)
|
||||
}
|
||||
addView(divider, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, NativeUiUtils.dp(activity, 1f)))
|
||||
}
|
||||
|
||||
// Insert the inputContainer into the main searchContainer
|
||||
container.addView(inputContainer, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
searchInput = input
|
||||
closeButton = button
|
||||
}
|
||||
|
||||
// Search Results Setup
|
||||
if (resultsContainer == null) {
|
||||
val scroll = ScrollView(activity)
|
||||
val inner = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
// Apply horizontal padding for the list of results
|
||||
setPadding(
|
||||
NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), // Left
|
||||
NativeUiUtils.dp(activity, 0f), // Top
|
||||
NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), // Right
|
||||
NativeUiUtils.dp(activity, 12f) // Bottom
|
||||
)
|
||||
}
|
||||
scroll.addView(inner, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
// The ScrollView should take up the rest of the vertical space
|
||||
container.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
resultsContainer = inner
|
||||
}
|
||||
|
||||
container.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun hideSearchUi() {
|
||||
searchContainer?.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun makeResultRow(result: SearchResult): View {
|
||||
val activity = activity ?: throw IllegalStateException("No activity")
|
||||
val theme = LogseqTheme.current()
|
||||
val labelColor = if (theme.isDark) theme.tint else Color.BLACK
|
||||
val secondaryLabelColor =
|
||||
if (theme.isDark) Color.argb(200, 245, 247, 250) else Color.DKGRAY
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
|
||||
// Apply vertical padding for the row item, using RESULT_ROW_VERTICAL_PADDING_DP (10f)
|
||||
// for both top and bottom to ensure they are equal.
|
||||
setPadding(0,
|
||||
NativeUiUtils.dp(activity, RESULT_ROW_VERTICAL_PADDING_DP), // TOP: 10f
|
||||
0,
|
||||
NativeUiUtils.dp(activity, RESULT_ROW_VERTICAL_PADDING_DP) // BOTTOM: 10f
|
||||
)
|
||||
|
||||
val subtitleText = result.subtitle
|
||||
if (subtitleText != null &&
|
||||
!subtitleText.isNullOrBlank() &&
|
||||
subtitleText.lowercase() != "null") {
|
||||
|
||||
val sub = TextView(activity).apply {
|
||||
text = subtitleText
|
||||
setTextColor(secondaryLabelColor)
|
||||
textSize = 13f
|
||||
}
|
||||
addView(sub)
|
||||
}
|
||||
|
||||
val titleView = TextView(activity).apply {
|
||||
text = result.title
|
||||
setTextColor(labelColor)
|
||||
textSize = 15f
|
||||
}
|
||||
addView(titleView)
|
||||
|
||||
setOnClickListener {
|
||||
hideSearchUi()
|
||||
notifyListeners("openSearchResultBlock", JSObject().put("id", result.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomNavBar(
|
||||
tabs: List<TabSpec>,
|
||||
currentId: String?,
|
||||
onSelect: (TabSpec) -> Unit
|
||||
) {
|
||||
val theme by LogseqTheme.colors.collectAsState()
|
||||
val container = ComposeColor(theme.background)
|
||||
val unselected = if (theme.isDark) ComposeColor(theme.tint).copy(alpha = 0.78f) else ComposeColor.Black.copy(alpha = 0.65f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(container)
|
||||
.padding(horizontal = TAB_BAR_HORIZONTAL_PADDING)
|
||||
) {
|
||||
NavigationBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
containerColor = container
|
||||
) {
|
||||
tabs.forEach { tab ->
|
||||
val selected = tab.id == currentId
|
||||
val icon = remember(tab.systemImage, tab.id) {
|
||||
MaterialIconResolver.resolve(tab.systemImage) ?: MaterialIconResolver.resolve(tab.id)
|
||||
}
|
||||
val accent = ComposeColor(NativeUiUtils.parseColor(ACCENT_COLOR_HEX, Color.parseColor(ACCENT_COLOR_HEX)))
|
||||
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = { onSelect(tab) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = accent,
|
||||
selectedTextColor = accent,
|
||||
unselectedIconColor = unselected,
|
||||
unselectedTextColor = unselected,
|
||||
indicatorColor = accent.copy(alpha = 0.12f)
|
||||
),
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = icon ?: Icons.Filled.Circle,
|
||||
contentDescription = tab.title
|
||||
)
|
||||
},
|
||||
// Slightly reduce the default Material3 gap between icon and label.
|
||||
label = { Text(tab.title, modifier = Modifier.offset(y = (-4).dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTabs(array: JSArray?): List<TabSpec> {
|
||||
if (array == null) return emptyList()
|
||||
val result = mutableListOf<TabSpec>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
val id = obj.optString("id", "")
|
||||
if (id.isBlank()) continue
|
||||
val title = obj.optString("title", id)
|
||||
val systemImage = obj.optString("systemImage", "")
|
||||
val role = obj.optString("role", "normal")
|
||||
result.add(TabSpec(id, title, systemImage, role))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseResults(array: JSArray?): List<SearchResult> {
|
||||
if (array == null) return emptyList()
|
||||
val result = mutableListOf<SearchResult>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
val id = obj.optString("id", "")
|
||||
if (id.isBlank()) continue
|
||||
val title = obj.optString("title", "")
|
||||
val subtitle = obj.optString("subtitle", null)
|
||||
result.add(SearchResult(id, title, subtitle))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data class TabSpec(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val systemImage: String,
|
||||
val role: String
|
||||
)
|
||||
|
||||
data class SearchResult(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String?
|
||||
)
|
||||
44
android/app/src/main/java/com/logseq/app/LogseqTheme.kt
Normal file
44
android/app/src/main/java/com/logseq/app/LogseqTheme.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class LogseqThemeColors(
|
||||
val background: Int,
|
||||
val tint: Int,
|
||||
val isDark: Boolean,
|
||||
)
|
||||
|
||||
object LogseqTheme {
|
||||
private val _colors = MutableStateFlow(compute(isDark = false))
|
||||
val colors: StateFlow<LogseqThemeColors> = _colors.asStateFlow()
|
||||
|
||||
fun update(context: Context) {
|
||||
_colors.value = compute(context)
|
||||
}
|
||||
|
||||
fun current(): LogseqThemeColors = _colors.value
|
||||
|
||||
fun isDark(context: Context): Boolean {
|
||||
val mask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
return mask == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
|
||||
private fun compute(context: Context): LogseqThemeColors = compute(isDark(context))
|
||||
|
||||
private fun compute(isDark: Boolean): LogseqThemeColors {
|
||||
val background =
|
||||
if (isDark) Color.parseColor("#002B36") else Color.parseColor("#FCFCFC")
|
||||
val tint =
|
||||
if (isDark) Color.parseColor("#F5F7FA") else Color.parseColor("#000000")
|
||||
return LogseqThemeColors(
|
||||
background = background,
|
||||
tint = tint,
|
||||
isDark = isDark,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
package com.logseq.app;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.webkit.ValueCallback;
|
||||
import android.webkit.WebView;
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import androidx.activity.OnBackPressedDispatcher;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
@@ -18,17 +23,55 @@ import java.util.TimerTask;
|
||||
import ee.forgr.capacitor_navigation_bar.NavigationBarPlugin;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
private NavigationCoordinator navigationCoordinator = new NavigationCoordinator();
|
||||
private BroadcastReceiver routeChangeReceiver;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(FolderPicker.class);
|
||||
registerPlugin(UILocal.class);
|
||||
registerPlugin(NativeTopBarPlugin.class);
|
||||
registerPlugin(NativeBottomSheetPlugin.class);
|
||||
registerPlugin(NativeEditorToolbarPlugin.class);
|
||||
registerPlugin(NativeSelectionActionBarPlugin.class);
|
||||
registerPlugin(LiquidTabsPlugin.class);
|
||||
registerPlugin(Utils.class);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
EdgeToEdge.enable(this);
|
||||
WebView webView = getBridge().getWebView();
|
||||
webView.setOverScrollMode(WebView.OVER_SCROLL_NEVER);
|
||||
webView.getSettings().setUseWideViewPort(true);
|
||||
webView.getSettings().setLoadWithOverviewMode(true);
|
||||
|
||||
applyLogseqTheme();
|
||||
|
||||
// Let Compose host the WebView with system bar padding for safe areas and handle back.
|
||||
ComposeHost.INSTANCE.renderWithSystemInsets(this, webView, () -> {
|
||||
sendJsBack(webView);
|
||||
return null;
|
||||
}, () -> {
|
||||
finish();
|
||||
return null;
|
||||
});
|
||||
|
||||
routeChangeReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (!UILocal.ACTION_ROUTE_CHANGED.equals(intent.getAction())) return;
|
||||
String stack = intent.getStringExtra("stack");
|
||||
String navigationType = intent.getStringExtra("navigationType");
|
||||
String path = intent.getStringExtra("path");
|
||||
navigationCoordinator.onRouteChange(stack, navigationType, path);
|
||||
}
|
||||
};
|
||||
IntentFilter filter = new IntentFilter(UILocal.ACTION_ROUTE_CHANGED);
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(routeChangeReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(routeChangeReceiver, filter);
|
||||
}
|
||||
|
||||
// initNavigationBarBgColor();
|
||||
|
||||
new Timer().schedule(new TimerTask() {
|
||||
@@ -44,6 +87,58 @@ public class MainActivity extends BridgeActivity {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
applyLogseqTheme();
|
||||
dispatchSystemThemeToWeb();
|
||||
}
|
||||
|
||||
private void applyLogseqTheme() {
|
||||
LogseqTheme.INSTANCE.update(this);
|
||||
LogseqThemeColors colors = LogseqTheme.INSTANCE.current();
|
||||
|
||||
int bg = colors.getBackground();
|
||||
boolean isDark = colors.isDark();
|
||||
|
||||
View content = findViewById(android.R.id.content);
|
||||
if (content != null) {
|
||||
content.setBackgroundColor(bg);
|
||||
}
|
||||
|
||||
WebView webView = getBridge() != null ? getBridge().getWebView() : null;
|
||||
if (webView != null) {
|
||||
webView.setBackgroundColor(bg);
|
||||
}
|
||||
|
||||
getWindow().getDecorView().setBackgroundColor(bg);
|
||||
getWindow().setStatusBarColor(bg);
|
||||
getWindow().setNavigationBarColor(bg);
|
||||
|
||||
WindowInsetsControllerCompat controller = new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView());
|
||||
controller.setAppearanceLightStatusBars(!isDark);
|
||||
controller.setAppearanceLightNavigationBars(!isDark);
|
||||
|
||||
WebViewSnapshotManager.INSTANCE.setSnapshotBackgroundColor(bg);
|
||||
}
|
||||
|
||||
public void applyLogseqThemeNow() {
|
||||
applyLogseqTheme();
|
||||
}
|
||||
|
||||
private void dispatchSystemThemeToWeb() {
|
||||
try {
|
||||
if (bridge == null) return;
|
||||
boolean isDark = LogseqTheme.INSTANCE.isDark(this);
|
||||
String js = "window.dispatchEvent(new CustomEvent('logseq:native-system-theme-changed', { detail: { isDark: "
|
||||
+ (isDark ? "true" : "false")
|
||||
+ " } }));";
|
||||
bridge.eval(js, null);
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
public void initNavigationBarBgColor() {
|
||||
NavigationBarPlugin navigationBarPlugin = new NavigationBarPlugin();
|
||||
JSObject data = new JSObject();
|
||||
@@ -59,6 +154,20 @@ public class MainActivity extends BridgeActivity {
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
Log.d("onBackPressed", "Debug");
|
||||
|
||||
WebView webView = getBridge().getWebView();
|
||||
if (webView != null) {
|
||||
// Send "native back" into JS. JS will call your UILocal/route-change,
|
||||
// which flows into ComposeHost.applyNavigation(...) and animates.
|
||||
sendJsBack(webView);
|
||||
} else {
|
||||
// Fallback if for some reason there is no webview
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
@@ -74,4 +183,21 @@ public class MainActivity extends BridgeActivity {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (routeChangeReceiver != null) {
|
||||
unregisterReceiver(routeChangeReceiver);
|
||||
routeChangeReceiver = null;
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void sendJsBack(WebView webView) {
|
||||
if (webView == null) return;
|
||||
webView.post(() -> webView.evaluateJavascript(
|
||||
"window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();",
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.logseq.app
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmarks
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Layers
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.CameraAlt
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.AddCircle
|
||||
import androidx.compose.material.icons.outlined.DataArray
|
||||
import androidx.compose.material.icons.outlined.KeyboardCommandKey
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.ArrowForward
|
||||
import androidx.compose.material.icons.outlined.BookmarkAdd
|
||||
import androidx.compose.material.icons.outlined.CalendarToday
|
||||
import androidx.compose.material.icons.outlined.CheckBox
|
||||
import androidx.compose.material.icons.outlined.Code
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Dashboard
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Equalizer
|
||||
import androidx.compose.material.icons.outlined.Explore
|
||||
import androidx.compose.material.icons.outlined.GraphicEq
|
||||
import androidx.compose.material.icons.outlined.KeyboardHide
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import androidx.compose.material.icons.outlined.LocalOffer
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.Redo
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.StarBorder
|
||||
import androidx.compose.material.icons.outlined.Undo
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
object MaterialIconResolver {
|
||||
fun resolve(name: String?): ImageVector? {
|
||||
val key = name
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
?.replace("_", "-")
|
||||
?.replace("\\s+".toRegex(), "-")
|
||||
?.replace(".", "-")
|
||||
?: return null
|
||||
|
||||
return when (key) {
|
||||
"chevron-backward", "arrow-left", "back" -> Icons.Outlined.ArrowBack
|
||||
"arrow-right" -> Icons.Outlined.ArrowForward
|
||||
"arrow-uturn-backward" -> Icons.Outlined.Undo
|
||||
"arrow-uturn-forward" -> Icons.Outlined.Redo
|
||||
"calendar" -> Icons.Outlined.CalendarToday
|
||||
"waveform", "audio", "equalizer" -> Icons.Outlined.GraphicEq
|
||||
"ellipsis" -> Icons.Outlined.MoreVert
|
||||
"star-fill" -> Icons.Filled.Star
|
||||
"star" -> Icons.Outlined.StarBorder
|
||||
"circle-fill" -> Icons.Filled.Circle
|
||||
"plus", "add" -> Icons.Outlined.Add
|
||||
"paperplane", "send" -> Icons.Filled.Send
|
||||
"todo", "checkmark-square" -> Icons.Outlined.CheckBox
|
||||
"number", "tag" -> Icons.Outlined.LocalOffer
|
||||
"parentheses" -> Icons.Outlined.DataArray
|
||||
"command", "slash" -> Icons.Outlined.KeyboardCommandKey
|
||||
"camera" -> Icons.Outlined.CameraAlt
|
||||
"keyboard-chevron-compact-down", "keyboard-hide" -> Icons.Outlined.KeyboardHide
|
||||
"doc-on-doc", "copy" -> Icons.Outlined.ContentCopy
|
||||
"trash", "delete" -> Icons.Outlined.Delete
|
||||
"r-square", "bookmark-ref" -> Icons.Outlined.BookmarkAdd
|
||||
"link" -> Icons.Outlined.Link
|
||||
"xmark", "close" -> Icons.Filled.Close
|
||||
"house", "home" -> Icons.Filled.Home
|
||||
"app-background-dotted" -> Icons.Outlined.Dashboard
|
||||
"tray", "add" -> Icons.Outlined.AddCircle
|
||||
"square-stack-3d-down-right", "layers" -> Icons.Filled.Layers
|
||||
"magnifyingglass", "search" -> Icons.Outlined.Search
|
||||
"go-to", "goto" -> Icons.Outlined.Explore
|
||||
"bookmark" -> Icons.Filled.Bookmarks
|
||||
"sync" -> Icons.Outlined.Equalizer
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
|
||||
@CapacitorPlugin(name = "NativeBottomSheetPlugin")
|
||||
class NativeBottomSheetPlugin : Plugin() {
|
||||
private val snapshotTag = "bottom-sheet"
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var dialog: BottomSheetDialog? = null
|
||||
private var previousParent: ViewGroup? = null
|
||||
private var previousIndex: Int = -1
|
||||
private var previousLayoutParams: ViewGroup.LayoutParams? = null
|
||||
private var placeholder: View? = null
|
||||
private var container: FrameLayout? = null
|
||||
|
||||
@PluginMethod
|
||||
fun present(call: PluginCall) {
|
||||
val activity = activity ?: run {
|
||||
call.reject("No activity")
|
||||
return
|
||||
}
|
||||
val webView = bridge.webView ?: run {
|
||||
call.reject("No webview")
|
||||
return
|
||||
}
|
||||
|
||||
activity.runOnUiThread {
|
||||
WebViewSnapshotManager.registerWindow(activity.window)
|
||||
if (dialog != null) {
|
||||
call.resolve()
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
val ctx = activity
|
||||
container = FrameLayout(ctx)
|
||||
container!!.layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
val sheet = BottomSheetDialog(ctx)
|
||||
sheet.setContentView(container!!)
|
||||
|
||||
WebViewSnapshotManager.showSnapshot(snapshotTag, webView)
|
||||
|
||||
// Move the WebView into the BottomSheet container
|
||||
detachWebView(webView, ctx)
|
||||
container!!.addView(
|
||||
webView,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
|
||||
val behavior = sheet.behavior
|
||||
val defaultHeight = call.getInt("defaultHeight", null)
|
||||
val allowFullHeight = call.getBoolean("allowFullHeight") ?: true
|
||||
if (defaultHeight != null) {
|
||||
val peek = NativeUiUtils.dp(ctx, defaultHeight.toFloat())
|
||||
behavior.peekHeight = peek
|
||||
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
} else {
|
||||
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
behavior.skipCollapsed = !allowFullHeight
|
||||
|
||||
sheet.setOnDismissListener {
|
||||
notifyListeners("state", JSObject().put("dismissing", true))
|
||||
|
||||
// Forcefully detach WebView before restoring
|
||||
try {
|
||||
(webView.parent as? ViewGroup)?.removeView(webView)
|
||||
container?.removeAllViews()
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Delay restoration slightly to let Android clean up window surfaces
|
||||
mainHandler.post {
|
||||
restoreWebView(webView)
|
||||
webView.alpha = 0f
|
||||
|
||||
webView.postDelayed({
|
||||
webView.alpha = 1f
|
||||
WebViewSnapshotManager.clearSnapshot(snapshotTag)
|
||||
notifyListeners(
|
||||
"state",
|
||||
JSObject()
|
||||
.put("presented", false)
|
||||
.put("dismissing", false)
|
||||
)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
dialog = null
|
||||
container = null
|
||||
}
|
||||
|
||||
notifyListeners("state", JSObject().put("presenting", true))
|
||||
sheet.show()
|
||||
notifyListeners("state", JSObject().put("presented", true))
|
||||
dialog = sheet
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun dismiss(call: PluginCall) {
|
||||
activity?.runOnUiThread {
|
||||
if (dialog == null) {
|
||||
WebViewSnapshotManager.clearSnapshot(snapshotTag)
|
||||
call.resolve()
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
dialog?.dismiss()
|
||||
call.resolve()
|
||||
} ?: call.resolve()
|
||||
}
|
||||
|
||||
private fun detachWebView(webView: View, ctx: android.content.Context) {
|
||||
val parent = webView.parent as? ViewGroup ?: return
|
||||
previousParent = parent
|
||||
previousIndex = parent.indexOfChild(webView)
|
||||
previousLayoutParams = webView.layoutParams
|
||||
|
||||
parent.removeView(webView)
|
||||
placeholder = View(ctx).apply {
|
||||
setBackgroundColor(LogseqTheme.current().background)
|
||||
layoutParams = previousLayoutParams ?: ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
parent.addView(placeholder, previousIndex)
|
||||
}
|
||||
|
||||
private fun restoreWebView(webView: View) {
|
||||
val parent = previousParent ?: return
|
||||
val lp = previousLayoutParams ?: ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
// Fully detach from any container
|
||||
(webView.parent as? ViewGroup)?.removeView(webView)
|
||||
placeholder?.let { parent.removeView(it) }
|
||||
placeholder = null
|
||||
|
||||
// Reattach WebView
|
||||
if (previousIndex in 0..parent.childCount) {
|
||||
parent.addView(webView, previousIndex, lp)
|
||||
} else {
|
||||
parent.addView(webView, lp)
|
||||
}
|
||||
|
||||
// ✅ Force WebView to recreate its SurfaceView and redraw
|
||||
webView.visibility = View.INVISIBLE
|
||||
webView.post {
|
||||
webView.visibility = View.VISIBLE
|
||||
webView.requestLayout()
|
||||
webView.invalidate()
|
||||
webView.dispatchWindowVisibilityChanged(View.VISIBLE)
|
||||
}
|
||||
|
||||
previousParent = null
|
||||
previousLayoutParams = null
|
||||
previousIndex = -1
|
||||
container = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "NativeEditorToolbarPlugin")
|
||||
class NativeEditorToolbarPlugin : Plugin() {
|
||||
private var toolbarView: EditorToolbarView? = null
|
||||
|
||||
@PluginMethod
|
||||
fun present(call: PluginCall) {
|
||||
val activity = activity ?: run {
|
||||
call.reject("No activity")
|
||||
return
|
||||
}
|
||||
|
||||
val actions = parseActions(call.getArray("actions"))
|
||||
val trailing = call.getObject("trailingAction")?.let { EditorAction.from(it) }
|
||||
val tintHex = call.getString("tintColor")
|
||||
val bgHex = call.getString("backgroundColor")
|
||||
|
||||
activity.runOnUiThread {
|
||||
if (actions.isEmpty() && trailing == null) {
|
||||
dismissInternal()
|
||||
call.resolve()
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
val view = toolbarView ?: EditorToolbarView(activity).also { v ->
|
||||
v.onAction = { id ->
|
||||
notifyListeners("action", JSObject().put("id", id))
|
||||
}
|
||||
toolbarView = v
|
||||
}
|
||||
|
||||
view.bind(actions, trailing, tintHex, bgHex)
|
||||
|
||||
val root = NativeUiUtils.contentRoot(activity)
|
||||
if (view.parent !== root) {
|
||||
NativeUiUtils.detachView(view)
|
||||
val lp = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM
|
||||
).apply {
|
||||
val margin = NativeUiUtils.dp(activity, 12f)
|
||||
setMargins(margin, margin, margin, margin)
|
||||
}
|
||||
root.addView(view, lp)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun dismiss(call: PluginCall) {
|
||||
activity?.runOnUiThread {
|
||||
dismissInternal()
|
||||
call.resolve()
|
||||
} ?: call.resolve()
|
||||
}
|
||||
|
||||
private fun dismissInternal() {
|
||||
val root = activity?.let { NativeUiUtils.contentRoot(it) } ?: return
|
||||
toolbarView?.let { root.removeView(it) }
|
||||
toolbarView = null
|
||||
}
|
||||
|
||||
private fun parseActions(array: JSArray?): List<EditorAction> {
|
||||
if (array == null) return emptyList()
|
||||
val result = mutableListOf<EditorAction>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
EditorAction.from(obj)?.let { result.add(it) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data class EditorAction(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val systemIcon: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(obj: org.json.JSONObject): EditorAction? {
|
||||
val id = obj.optString("id", "")
|
||||
if (id.isBlank()) return null
|
||||
val title = obj.optString("title", id)
|
||||
val icon = obj.optString("systemIcon", null)
|
||||
return EditorAction(id, title, icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EditorToolbarView(context: android.content.Context) : FrameLayout(context) {
|
||||
var onAction: ((String) -> Unit)? = null
|
||||
|
||||
private val composeView = ComposeView(context).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
private var actions: List<EditorAction> = emptyList()
|
||||
private var trailing: EditorAction? = null
|
||||
private var tint: Int = defaultTint()
|
||||
private var backgroundColor: Int = defaultBackground()
|
||||
|
||||
init {
|
||||
addView(composeView)
|
||||
}
|
||||
|
||||
fun bind(
|
||||
actions: List<EditorAction>,
|
||||
trailing: EditorAction?,
|
||||
tintHex: String?,
|
||||
bgHex: String?
|
||||
) {
|
||||
this.actions = actions
|
||||
this.trailing = trailing
|
||||
tint = NativeUiUtils.parseColor(tintHex, defaultTint())
|
||||
backgroundColor = NativeUiUtils.parseColor(bgHex, defaultBackground())
|
||||
render()
|
||||
}
|
||||
|
||||
private fun defaultTint(): Int =
|
||||
if (LogseqTheme.current().isDark) Color.WHITE else Color.BLACK
|
||||
|
||||
private fun defaultBackground(): Int = LogseqTheme.current().background
|
||||
|
||||
private fun render() {
|
||||
val onActionFn = onAction
|
||||
val actionsSnapshot = actions
|
||||
val trailingSnapshot = trailing
|
||||
val tintColor = tint
|
||||
val bgColor = backgroundColor
|
||||
|
||||
composeView.setContent {
|
||||
EditorToolbar(
|
||||
actions = actionsSnapshot,
|
||||
trailing = trailingSnapshot,
|
||||
tint = ComposeColor(tintColor),
|
||||
background = ComposeColor(bgColor),
|
||||
onAction = { id -> onActionFn?.invoke(id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorToolbar(
|
||||
actions: List<EditorAction>,
|
||||
trailing: EditorAction?,
|
||||
tint: ComposeColor,
|
||||
background: ComposeColor,
|
||||
onAction: (String) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = background,
|
||||
shadowElevation = 6.dp,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.navigationBarsPadding()
|
||||
.imePadding() // Lift toolbar above system nav/IME when the keyboard opens
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
actions.forEach { action ->
|
||||
ToolbarButton(action, tint, onAction)
|
||||
}
|
||||
}
|
||||
|
||||
trailing?.let { trailingAction ->
|
||||
if (actions.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
ToolbarButton(trailingAction, tint, onAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolbarButton(
|
||||
action: EditorAction,
|
||||
tint: ComposeColor,
|
||||
onAction: (String) -> Unit
|
||||
) {
|
||||
val icon = remember(action.systemIcon) { MaterialIconResolver.resolve(action.systemIcon) }
|
||||
val contentTint = remember(tint) { tint.copy(alpha = 0.8f) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 44.dp)
|
||||
.clickable { onAction(action.id) }
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = action.title.ifBlank { action.id },
|
||||
tint = contentTint,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 20.dp)
|
||||
.padding(end = 2.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = action.title,
|
||||
color = contentTint,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlin.math.max
|
||||
|
||||
@CapacitorPlugin(name = "NativeSelectionActionBarPlugin")
|
||||
class NativeSelectionActionBarPlugin : Plugin() {
|
||||
private var barView: SelectionActionBarView? = null
|
||||
|
||||
@PluginMethod
|
||||
fun present(call: PluginCall) {
|
||||
val activity = activity ?: run {
|
||||
call.reject("No activity")
|
||||
return
|
||||
}
|
||||
|
||||
val actionsArray = call.getArray("actions")
|
||||
val actions = parseActions(actionsArray)
|
||||
val tintHex = call.getString("tintColor")
|
||||
val bgHex = call.getString("backgroundColor")
|
||||
|
||||
activity.runOnUiThread {
|
||||
if (actions.isEmpty()) {
|
||||
dismissInternal()
|
||||
call.resolve()
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
val root = NativeUiUtils.contentRoot(activity)
|
||||
val view = barView ?: SelectionActionBarView(activity).also { v ->
|
||||
v.onAction = { id ->
|
||||
notifyListeners("action", JSObject().put("id", id))
|
||||
}
|
||||
barView = v
|
||||
}
|
||||
|
||||
view.bind(actions, tintHex, bgHex)
|
||||
|
||||
if (view.parent !== root) {
|
||||
NativeUiUtils.detachView(view)
|
||||
|
||||
val lp = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM
|
||||
).apply {
|
||||
val margin = NativeUiUtils.dp(activity, 12f)
|
||||
val bottomOffset = computeBottomOffset(activity, root)
|
||||
// top / left / right: margin
|
||||
// bottom: margin + bottom nav height + system/IME inset
|
||||
setMargins(margin, margin, margin, margin + bottomOffset)
|
||||
}
|
||||
|
||||
root.addView(view, lp)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun dismiss(call: PluginCall) {
|
||||
activity?.runOnUiThread {
|
||||
dismissInternal()
|
||||
call.resolve()
|
||||
} ?: call.resolve()
|
||||
}
|
||||
|
||||
private fun dismissInternal() {
|
||||
val activity = activity ?: return
|
||||
val root = NativeUiUtils.contentRoot(activity)
|
||||
|
||||
barView?.let { root.removeView(it) }
|
||||
barView = null
|
||||
}
|
||||
|
||||
private fun parseActions(array: JSArray?): List<SelectionAction> {
|
||||
if (array == null) return emptyList()
|
||||
val result = mutableListOf<SelectionAction>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
SelectionAction.from(obj)?.let { result.add(it) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute how far we must lift the bar from the bottom so that it
|
||||
* sits above:
|
||||
* - the BottomNavigationView created by LiquidTabsPlugin
|
||||
* - system nav / gesture bar
|
||||
* - IME (when showing)
|
||||
*/
|
||||
private fun computeBottomOffset(activity: android.app.Activity, root: ViewGroup): Int {
|
||||
val insets = ViewCompat.getRootWindowInsets(root)
|
||||
val systemBarsBottom = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0
|
||||
val imeBottom = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
|
||||
|
||||
// Find the bottom nav created by LiquidTabsPlugin (must have this ID set there)
|
||||
val bottomNav = activity.findViewById<View>(R.id.liquid_tabs_bottom_nav)
|
||||
// Fallback height if nav not measured yet
|
||||
val navHeight = bottomNav?.height ?: NativeUiUtils.dp(activity, 56f)
|
||||
|
||||
val insetBottom = max(systemBarsBottom, imeBottom)
|
||||
return navHeight + insetBottom
|
||||
}
|
||||
}
|
||||
|
||||
data class SelectionAction(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val systemIcon: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(obj: org.json.JSONObject): SelectionAction? {
|
||||
val id = obj.optString("id", "")
|
||||
if (id.isBlank()) return null
|
||||
val title = obj.optString("title", id)
|
||||
val icon = obj.optString("systemIcon", null)
|
||||
return SelectionAction(id, title, icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SelectionActionBarView(context: android.content.Context) : FrameLayout(context) {
|
||||
var onAction: ((String) -> Unit)? = null
|
||||
private val composeView: ComposeView
|
||||
|
||||
init {
|
||||
composeView = ComposeView(context).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
addView(composeView)
|
||||
}
|
||||
|
||||
fun bind(actions: List<SelectionAction>, tintHex: String?, bgHex: String?) {
|
||||
val defaultTint = if (LogseqTheme.current().isDark) Color.WHITE else Color.BLACK
|
||||
val tint = ComposeColor(NativeUiUtils.parseColor(tintHex, defaultTint))
|
||||
val backgroundColor = ComposeColor(NativeUiUtils.parseColor(bgHex, LogseqTheme.current().background))
|
||||
val onActionFn = onAction
|
||||
|
||||
composeView.setContent {
|
||||
SelectionActionBar(actions, tint, backgroundColor) { id ->
|
||||
onActionFn?.invoke(id)
|
||||
}
|
||||
}
|
||||
composeView.doOnNextLayout { requestLayout() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectionActionBar(
|
||||
actions: List<SelectionAction>,
|
||||
tint: ComposeColor,
|
||||
background: ComposeColor,
|
||||
onAction: (String) -> Unit
|
||||
) {
|
||||
val (mainActions, trailingAction) = remember(actions) {
|
||||
val primary = if (actions.size > 1) actions.dropLast(1) else emptyList()
|
||||
Pair(primary, actions.lastOrNull())
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Surface(
|
||||
color = background,
|
||||
shadowElevation = 6.dp,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (mainActions.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.horizontalScroll(scrollState),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
mainActions.forEach { action ->
|
||||
SelectionActionButton(action, tint, onAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trailingAction?.let { action ->
|
||||
if (mainActions.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Divider(
|
||||
modifier = Modifier
|
||||
.height(28.dp)
|
||||
.width(1.dp),
|
||||
color = tint.copy(alpha = 0.15f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
SelectionActionButton(action, tint, onAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectionActionButton(
|
||||
action: SelectionAction,
|
||||
tint: ComposeColor,
|
||||
onAction: (String) -> Unit
|
||||
) {
|
||||
val icon = remember(action.systemIcon) { MaterialIconResolver.resolve(action.systemIcon) }
|
||||
val contentTint = remember(tint) { tint.copy(alpha = 0.8f) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 56.dp)
|
||||
.clickable { onAction(action.id) }
|
||||
.padding(horizontal = 6.dp, vertical = 8.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = action.title.ifBlank { action.id },
|
||||
tint = contentTint,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = action.title,
|
||||
color = contentTint,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
331
android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt
Normal file
331
android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt
Normal file
@@ -0,0 +1,331 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "NativeTopBarPlugin")
|
||||
class NativeTopBarPlugin : Plugin() {
|
||||
private var topBarView: NativeTopBarView? = null
|
||||
private var originalWebViewPaddingTop: Int? = null
|
||||
|
||||
@PluginMethod
|
||||
fun configure(call: PluginCall) {
|
||||
val activity = activity ?: run {
|
||||
call.reject("No activity")
|
||||
return
|
||||
}
|
||||
|
||||
activity.runOnUiThread {
|
||||
val hidden = call.getBoolean("hidden") ?: false
|
||||
val title = call.getString("title") ?: ""
|
||||
val leftButtons = parseButtons(call.getArray("leftButtons"))
|
||||
val rightButtons = parseButtons(call.getArray("rightButtons"))
|
||||
val titleClickable = call.getBoolean("titleClickable") ?: false
|
||||
val tintHex = call.getString("tintColor")
|
||||
val tintColorOverride =
|
||||
tintHex?.takeIf { it.isNotBlank() }?.let { NativeUiUtils.parseColor(it, LogseqTheme.current().tint) }
|
||||
|
||||
val webView = bridge.webView
|
||||
|
||||
if (hidden) {
|
||||
removeBar()
|
||||
restorePadding(webView)
|
||||
call.resolve()
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
val bar = topBarView ?: NativeTopBarView(activity).also { view ->
|
||||
view.onTap = { id ->
|
||||
notifyListeners("buttonTapped", JSObject().put("id", id))
|
||||
}
|
||||
attachBar(view)
|
||||
topBarView = view
|
||||
}
|
||||
|
||||
bar.bind(title, titleClickable, leftButtons, rightButtons, tintColorOverride)
|
||||
bar.post {
|
||||
adjustWebViewPadding(webView, bar.height)
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachBar(bar: NativeTopBarView) {
|
||||
val root = NativeUiUtils.contentRoot(activity)
|
||||
if (bar.parent !== root) {
|
||||
NativeUiUtils.detachView(bar)
|
||||
val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
root.addView(bar, lp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeBar() {
|
||||
val root = activity?.let { NativeUiUtils.contentRoot(it) } ?: return
|
||||
topBarView?.let { view ->
|
||||
root.removeView(view)
|
||||
}
|
||||
topBarView = null
|
||||
}
|
||||
|
||||
private fun statusBarInset(webView: android.webkit.WebView?): Int {
|
||||
if (webView == null) return 0
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
webView.rootWindowInsets?.getInsets(android.view.WindowInsets.Type.statusBars())?.top ?: 0
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
webView.rootWindowInsets?.stableInsetTop ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun adjustWebViewPadding(webView: android.webkit.WebView?, barHeight: Int) {
|
||||
if (webView == null) return
|
||||
if (originalWebViewPaddingTop == null) {
|
||||
originalWebViewPaddingTop = webView.paddingTop
|
||||
}
|
||||
val insetTop = statusBarInset(webView)
|
||||
val target = (barHeight - insetTop).coerceAtLeast(0)
|
||||
.takeIf { it > 0 } ?: NativeUiUtils.dp(webView.context, 56f)
|
||||
webView.setPadding(
|
||||
webView.paddingLeft,
|
||||
target,
|
||||
webView.paddingRight,
|
||||
webView.paddingBottom
|
||||
)
|
||||
}
|
||||
|
||||
private fun restorePadding(webView: android.webkit.WebView?) {
|
||||
if (webView == null) return
|
||||
val original = originalWebViewPaddingTop
|
||||
if (original != null) {
|
||||
webView.setPadding(webView.paddingLeft, original, webView.paddingRight, webView.paddingBottom)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseButtons(array: JSArray?): List<ButtonSpec> {
|
||||
if (array == null) return emptyList()
|
||||
val result = mutableListOf<ButtonSpec>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
val id = obj.optString("id", "")
|
||||
if (id.isBlank()) continue
|
||||
val systemIcon = obj.optString("systemIcon", "")
|
||||
val title = obj.optString("title", id)
|
||||
val tintHex = obj.optString("tintColor", obj.optString("color", ""))
|
||||
val iconSize = if (id == "sync") {
|
||||
"small"
|
||||
} else {
|
||||
"medium"
|
||||
}
|
||||
|
||||
val size = obj.optString("size", iconSize)
|
||||
result.add(
|
||||
ButtonSpec(
|
||||
id = id,
|
||||
title = if (title.isNotBlank()) title else id,
|
||||
systemIcon = systemIcon.takeIf { it.isNotBlank() },
|
||||
tint = tintHex,
|
||||
size = size
|
||||
)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data class ButtonSpec(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val systemIcon: String?,
|
||||
val tint: String?,
|
||||
val size: String
|
||||
)
|
||||
|
||||
private class NativeTopBarView(context: android.content.Context) : FrameLayout(context) {
|
||||
var onTap: ((String) -> Unit)? = null
|
||||
private val composeView = ComposeView(context).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
init {
|
||||
addView(composeView)
|
||||
}
|
||||
|
||||
fun bind(
|
||||
title: String,
|
||||
titleClickable: Boolean,
|
||||
leftButtons: List<ButtonSpec>,
|
||||
rightButtons: List<ButtonSpec>,
|
||||
tintColorOverride: Int?
|
||||
) {
|
||||
val onTapFn = onTap
|
||||
composeView.setContent {
|
||||
TopBarContent(
|
||||
title = title,
|
||||
titleClickable = titleClickable,
|
||||
leftButtons = leftButtons,
|
||||
rightButtons = rightButtons,
|
||||
tintOverride = tintColorOverride,
|
||||
onTap = { id -> onTapFn?.invoke(id) }
|
||||
)
|
||||
}
|
||||
doOnNextLayout {
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopBarContent(
|
||||
title: String,
|
||||
titleClickable: Boolean,
|
||||
leftButtons: List<ButtonSpec>,
|
||||
rightButtons: List<ButtonSpec>,
|
||||
tintOverride: Int?,
|
||||
onTap: (String) -> Unit
|
||||
) {
|
||||
val theme by LogseqTheme.colors.collectAsState()
|
||||
val background = ComposeColor(theme.background)
|
||||
val tint = tintOverride?.let { ComposeColor(it) } ?: ComposeColor(theme.tint)
|
||||
val contentTint = tint.copy(alpha = 0.8f)
|
||||
|
||||
Surface(
|
||||
color = background,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
leftButtons.forEachIndexed { index, button ->
|
||||
if (index > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
TopBarButton(button, contentTint, onTap)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
color = contentTint,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp)
|
||||
.let { mod ->
|
||||
if (titleClickable) {
|
||||
mod.clickable { onTap("title") }
|
||||
} else {
|
||||
mod
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
rightButtons.forEachIndexed { index, button ->
|
||||
if (index > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
TopBarButton(button, contentTint, onTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopBarButton(
|
||||
spec: ButtonSpec,
|
||||
fallbackTint: ComposeColor,
|
||||
onTap: (String) -> Unit
|
||||
) {
|
||||
val icon = remember(spec.systemIcon) { MaterialIconResolver.resolve(spec.systemIcon) }
|
||||
val baseTint = remember(spec.tint, fallbackTint) {
|
||||
spec.tint?.let { ComposeColor(NativeUiUtils.parseColor(it, fallbackTint.toArgb())) } ?: fallbackTint
|
||||
}
|
||||
val tint = remember(baseTint) { baseTint.copy(alpha = 0.8f) }
|
||||
val fontSize = when (spec.size.lowercase()) {
|
||||
"small" -> 13.sp
|
||||
"large" -> 17.sp
|
||||
else -> 15.sp
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable { onTap(spec.id) }
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
val iconSize = if (spec.size.lowercase() == "small") {
|
||||
12.dp
|
||||
} else {
|
||||
22.dp
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = spec.title.ifBlank { spec.id },
|
||||
tint = tint,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = spec.title,
|
||||
color = tint,
|
||||
fontSize = fontSize,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
android/app/src/main/java/com/logseq/app/NativeUiUtils.kt
Normal file
31
android/app/src/main/java/com/logseq/app/NativeUiUtils.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
|
||||
object NativeUiUtils {
|
||||
fun dp(context: Context, value: Float): Int =
|
||||
(value * context.resources.displayMetrics.density).toInt()
|
||||
|
||||
fun parseColor(hex: String?, defaultColor: Int): Int {
|
||||
if (hex.isNullOrBlank()) return defaultColor
|
||||
return try {
|
||||
Color.parseColor(hex)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
defaultColor
|
||||
}
|
||||
}
|
||||
|
||||
fun contentRoot(activity: Activity): FrameLayout =
|
||||
activity.findViewById(android.R.id.content)
|
||||
|
||||
fun detachView(view: View?): ViewGroup? {
|
||||
val parent = view?.parent as? ViewGroup ?: return null
|
||||
parent.removeView(view)
|
||||
return parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.logseq.app
|
||||
|
||||
class NavigationCoordinator {
|
||||
private val primaryStack = "home"
|
||||
private val stackPaths: MutableMap<String, MutableList<String>> = mutableMapOf(
|
||||
primaryStack to mutableListOf("/")
|
||||
)
|
||||
var activeStackId: String = primaryStack
|
||||
private set
|
||||
|
||||
private fun defaultPath(stack: String): String =
|
||||
if (stack == primaryStack) "/" else "/__stack__/$stack"
|
||||
|
||||
private fun normalizedPath(raw: String?): String =
|
||||
raw?.takeIf { it.isNotBlank() } ?: "/"
|
||||
|
||||
fun onRouteChange(stack: String?, navigationType: String?, path: String?) {
|
||||
val stackId = stack?.takeIf { it.isNotBlank() } ?: primaryStack
|
||||
val navType = navigationType?.lowercase() ?: "push"
|
||||
val resolvedPath = normalizedPath(path)
|
||||
|
||||
val paths = stackPaths.getOrPut(stackId) { mutableListOf(defaultPath(stackId)) }
|
||||
|
||||
when (navType) {
|
||||
"reset" -> {
|
||||
paths.clear()
|
||||
paths.add(resolvedPath)
|
||||
}
|
||||
|
||||
"replace" -> {
|
||||
if (paths.isEmpty()) {
|
||||
paths.add(resolvedPath)
|
||||
} else {
|
||||
paths[paths.lastIndex] = resolvedPath
|
||||
}
|
||||
}
|
||||
|
||||
"pop" -> {
|
||||
if (paths.size > 1) {
|
||||
paths.removeAt(paths.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
else -> { // push (default)
|
||||
if (paths.isEmpty()) {
|
||||
paths.add(resolvedPath)
|
||||
} else if (paths.last() != resolvedPath) {
|
||||
paths.add(resolvedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: reset home stack to root when path is "/"
|
||||
if (stackId == primaryStack && resolvedPath == "/") {
|
||||
paths.clear()
|
||||
paths.add("/")
|
||||
}
|
||||
|
||||
activeStackId = stackId
|
||||
stackPaths[stackId] = paths
|
||||
}
|
||||
|
||||
fun canPop(): Boolean {
|
||||
val paths = stackPaths[activeStackId] ?: return false
|
||||
return paths.size > 1
|
||||
}
|
||||
|
||||
fun pop(): String? {
|
||||
val paths = stackPaths[activeStackId] ?: return null
|
||||
if (paths.size <= 1) return null
|
||||
paths.removeAt(paths.lastIndex)
|
||||
return paths.lastOrNull()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.DatePickerDialog
|
||||
import android.os.Build
|
||||
import android.content.Intent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.DatePicker
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.RequiresApi
|
||||
import android.widget.Toast
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
@@ -19,14 +18,17 @@ import java.util.Locale
|
||||
|
||||
@CapacitorPlugin(name = "UILocal")
|
||||
class UILocal : Plugin() {
|
||||
private var toast: Toast? = null
|
||||
companion object {
|
||||
const val ACTION_ROUTE_CHANGED = "com.logseq.app.ROUTE_DID_CHANGE"
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@PluginMethod
|
||||
fun showDatePicker(call: PluginCall) {
|
||||
val defaultDate = call.getString("defaultDate")
|
||||
val calendar = Calendar.getInstance()
|
||||
|
||||
if (defaultDate.isNullOrEmpty()) {
|
||||
if (!defaultDate.isNullOrEmpty()) {
|
||||
try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
val date = defaultDate?.let { sdf.parse(it) }
|
||||
@@ -85,4 +87,57 @@ class UILocal : Plugin() {
|
||||
call.reject("Error showing date picker", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun alert(call: PluginCall) {
|
||||
val message = call.getString("title") ?: call.getString("message")
|
||||
val duration = if ((call.getDouble("duration") ?: 0.0) > 3.5) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
|
||||
val ctx = context ?: run {
|
||||
call.reject("No context")
|
||||
return
|
||||
}
|
||||
if (message.isNullOrBlank()) {
|
||||
call.reject("title or message is required")
|
||||
return
|
||||
}
|
||||
toast?.cancel()
|
||||
toast = Toast.makeText(ctx, message, duration).also { it.show() }
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun hideAlert(call: PluginCall) {
|
||||
toast?.cancel()
|
||||
toast = null
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun routeDidChange(call: PluginCall) {
|
||||
val navigationType = call.getString("navigationType") ?: "push"
|
||||
val push = call.getBoolean("push") ?: (navigationType == "push")
|
||||
val path = call.getString("path") ?: "/"
|
||||
val stack = call.getString("stack") ?: "home"
|
||||
|
||||
// Drive Compose Nav for native animations/back handling.
|
||||
ComposeHost.applyNavigation(navigationType, path)
|
||||
|
||||
val ctx = context
|
||||
if (ctx != null) {
|
||||
val intent = Intent(ACTION_ROUTE_CHANGED).apply {
|
||||
putExtra("navigationType", navigationType)
|
||||
putExtra("push", push)
|
||||
putExtra("stack", stack)
|
||||
putExtra("path", path)
|
||||
}
|
||||
ctx.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun transcribeAudio2Text(call: PluginCall) {
|
||||
call.reject("transcription not supported on Android")
|
||||
}
|
||||
}
|
||||
|
||||
38
android/app/src/main/java/com/logseq/app/Utils.kt
Normal file
38
android/app/src/main/java/com/logseq/app/Utils.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.logseq.app
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "Utils")
|
||||
class Utils : Plugin() {
|
||||
@PluginMethod
|
||||
fun setInterfaceStyle(call: PluginCall) {
|
||||
val mode = (call.getString("mode") ?: "system").lowercase()
|
||||
val system = call.getBoolean("system") ?: (mode == "system")
|
||||
|
||||
val nightMode =
|
||||
if (system || mode == "system") {
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
} else if (mode == "dark") {
|
||||
AppCompatDelegate.MODE_NIGHT_YES
|
||||
} else {
|
||||
AppCompatDelegate.MODE_NIGHT_NO
|
||||
}
|
||||
|
||||
val activity = activity
|
||||
if (activity == null) {
|
||||
call.reject("No activity")
|
||||
return
|
||||
}
|
||||
|
||||
activity.runOnUiThread {
|
||||
AppCompatDelegate.setDefaultNightMode(nightMode)
|
||||
(activity as? MainActivity)?.applyLogseqThemeNow()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.logseq.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.PixelCopy
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Utility to capture lightweight WebView snapshots and show them in the overlay container.
|
||||
* Used to keep the background stable while the shared WebView is being reparented
|
||||
* (navigation transitions, bottom sheet presentation/dismiss).
|
||||
*/
|
||||
object WebViewSnapshotManager {
|
||||
private var overlayRef: WeakReference<FrameLayout>? = null
|
||||
private var windowRef: WeakReference<Window>? = null
|
||||
private val snapshotRefs: MutableMap<String, WeakReference<View>> = mutableMapOf()
|
||||
private val containerRefs: MutableMap<String, WeakReference<FrameLayout>> = mutableMapOf()
|
||||
private var snapshotBackgroundColor: Int = LogseqTheme.current().background
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun setSnapshotBackgroundColor(color: Int) {
|
||||
snapshotBackgroundColor = color
|
||||
overlayRef?.get()?.setBackgroundColor(Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
fun registerWindow(window: Window?) {
|
||||
windowRef = window?.let { WeakReference(it) }
|
||||
}
|
||||
|
||||
fun registerOverlay(overlay: FrameLayout?) {
|
||||
overlayRef = overlay?.let { WeakReference(it) }
|
||||
}
|
||||
|
||||
fun showSnapshot(tag: String, webView: View): View? {
|
||||
val overlay = ensureOverlay(webView) ?: return null
|
||||
clearSnapshot(tag)
|
||||
overlay.visibility = View.VISIBLE
|
||||
|
||||
val snapshotView = makeSnapshotView(webView)
|
||||
|
||||
overlay.addView(
|
||||
snapshotView,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
overlay.bringChildToFront(snapshotView)
|
||||
|
||||
snapshotRefs[tag] = WeakReference(snapshotView)
|
||||
containerRefs[tag] = WeakReference(overlay)
|
||||
|
||||
return snapshotView
|
||||
}
|
||||
|
||||
fun clearSnapshot(tag: String) {
|
||||
val view = snapshotRefs.remove(tag)?.get()
|
||||
val container = containerRefs.remove(tag)?.get()
|
||||
if (view != null && container != null) {
|
||||
container.removeView(view)
|
||||
if (container.childCount == 0) {
|
||||
container.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
snapshotRefs.keys.toList().forEach { clearSnapshot(it) }
|
||||
}
|
||||
|
||||
private fun ensureOverlay(webView: View): FrameLayout? {
|
||||
overlayRef?.get()?.let { return it }
|
||||
val root = webView.rootView
|
||||
val overlay = root.findViewById<FrameLayout>(R.id.webview_overlay_container)
|
||||
if (overlay != null) {
|
||||
overlayRef = WeakReference(overlay)
|
||||
}
|
||||
return overlay
|
||||
}
|
||||
|
||||
private fun makeSnapshotView(webView: View): View {
|
||||
val width = webView.width
|
||||
val height = webView.height
|
||||
if (width <= 0 || height <= 0) {
|
||||
return View(webView.context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setBackgroundColor(snapshotBackgroundColor)
|
||||
isClickable = false
|
||||
isFocusable = false
|
||||
}
|
||||
}
|
||||
|
||||
// Limit bitmap size to avoid OOM (e.g., max 4096x4096 or 16MB)
|
||||
val MAX_BITMAP_DIMENSION = 4096
|
||||
val MAX_BITMAP_SIZE = 16 * 1024 * 1024 // 16MB
|
||||
val safeWidth = width.coerceAtMost(MAX_BITMAP_DIMENSION)
|
||||
val safeHeight = height.coerceAtMost(MAX_BITMAP_DIMENSION)
|
||||
val estimatedSize = safeWidth * safeHeight * 4
|
||||
if (estimatedSize > MAX_BITMAP_SIZE) {
|
||||
// Fallback: show plain color view if too large
|
||||
return View(webView.context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setBackgroundColor(snapshotBackgroundColor)
|
||||
isClickable = false
|
||||
isFocusable = false
|
||||
}
|
||||
}
|
||||
|
||||
val bitmap = try {
|
||||
Bitmap.createBitmap(safeWidth, safeHeight, Bitmap.Config.ARGB_8888)
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// Fallback: show plain color view if OOM
|
||||
return View(webView.context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setBackgroundColor(snapshotBackgroundColor)
|
||||
isClickable = false
|
||||
isFocusable = false
|
||||
}
|
||||
}
|
||||
// Fast fallback: capture via View#draw (can be imperfect for WebView on some devices).
|
||||
try {
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(snapshotBackgroundColor)
|
||||
// If we had to scale down, scale the canvas
|
||||
if (safeWidth != width || safeHeight != height) {
|
||||
val scaleX = safeWidth.toFloat() / width
|
||||
val scaleY = safeHeight.toFloat() / height
|
||||
canvas.scale(scaleX, scaleY)
|
||||
}
|
||||
webView.draw(canvas)
|
||||
} catch (_: Exception) {
|
||||
// Keep bitmap with background color only.
|
||||
}
|
||||
|
||||
val imageView = ImageView(webView.context).apply {
|
||||
setImageBitmap(bitmap)
|
||||
scaleType = ImageView.ScaleType.FIT_XY
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setBackgroundColor(snapshotBackgroundColor)
|
||||
isClickable = false
|
||||
isFocusable = false
|
||||
}
|
||||
|
||||
// Higher-fidelity capture: PixelCopy from the Window buffer.
|
||||
val window = windowRef?.get()
|
||||
if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
val loc = IntArray(2)
|
||||
webView.getLocationInWindow(loc)
|
||||
val rect = Rect(loc[0], loc[1], loc[0] + width, loc[1] + height)
|
||||
|
||||
PixelCopy.request(window, rect, bitmap, { result ->
|
||||
if (result == PixelCopy.SUCCESS) {
|
||||
imageView.invalidate()
|
||||
}
|
||||
}, mainHandler)
|
||||
} catch (_: Exception) {
|
||||
// Ignore; fallback bitmap already set.
|
||||
}
|
||||
}
|
||||
|
||||
return imageView
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="logoPrimary">#0f262e</color>
|
||||
<color name="colorPrimary">#f7f7f7</color>
|
||||
<!-- Main brand color (HSL 200 97% 37%) -->
|
||||
<color name="colorPrimary">#037DBA</color>
|
||||
<color name="colorAccent">#6097c7</color>
|
||||
<color name="colorPrimaryDark">#0f262e</color>
|
||||
</resources>
|
||||
|
||||
5
android/app/src/main/res/values/ids.xml
Normal file
5
android/app/src/main/res/values/ids.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<item name="webview_container" type="id"/>
|
||||
<item name="webview_overlay_container" type="id"/>
|
||||
<item name="liquid_tabs_bottom_nav" type="id"/>
|
||||
</resources>
|
||||
@@ -14,6 +14,7 @@
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:navigationBarColor">@color/colorPrimary</item>
|
||||
<item name="android:statusBarColor">@color/colorPrimary</item>
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
|
||||
ext {
|
||||
kotlin_version = '2.1.21'
|
||||
kotlin_version = '2.0.21'
|
||||
composeCompilerVersion = '2.0.1'
|
||||
androidxActivityVersion = '1.9.2'
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
@@ -14,6 +16,7 @@ buildscript {
|
||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$composeCompilerVersion"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
@@ -33,4 +36,3 @@ allprojects {
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ ext {
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxNavigationVersion = '2.9.6'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
@@ -13,4 +14,7 @@ ext {
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
materialVersion = '1.12.0'
|
||||
composeBomVersion = '2024.09.02'
|
||||
composeCompilerVersion = '1.7.0'
|
||||
}
|
||||
|
||||
@@ -22,19 +22,27 @@
|
||||
(defn convert-file-src [path-str]
|
||||
(.convertFileSrc Capacitor path-str))
|
||||
|
||||
(defn plugin-available?
|
||||
"Check if a native plugin is available from Capacitor.Plugins."
|
||||
[name]
|
||||
(boolean (aget (.-Plugins js/Capacitor) name)))
|
||||
|
||||
(defonce folder-picker (registerPlugin "FolderPicker"))
|
||||
(defonce ui-local (registerPlugin "UILocal"))
|
||||
(defonce native-top-bar nil)
|
||||
(defonce native-bottom-sheet nil)
|
||||
(defonce native-editor-toolbar 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-editor-toolbar (registerPlugin "NativeEditorToolbarPlugin"))
|
||||
(set! native-selection-action-bar (registerPlugin "NativeSelectionActionBarPlugin"))
|
||||
(set! ios-utils (registerPlugin "Utils")))
|
||||
(defonce native-top-bar (when (and (native-platform?)
|
||||
(plugin-available? "NativeTopBarPlugin"))
|
||||
(registerPlugin "NativeTopBarPlugin")))
|
||||
(defonce native-bottom-sheet (when (and (native-platform?)
|
||||
(plugin-available? "NativeBottomSheetPlugin"))
|
||||
(registerPlugin "NativeBottomSheetPlugin")))
|
||||
(defonce native-editor-toolbar (when (and (native-platform?)
|
||||
(plugin-available? "NativeEditorToolbarPlugin"))
|
||||
(registerPlugin "NativeEditorToolbarPlugin")))
|
||||
(defonce native-selection-action-bar (when (and (native-platform?)
|
||||
(plugin-available? "NativeSelectionActionBarPlugin"))
|
||||
(registerPlugin "NativeSelectionActionBarPlugin")))
|
||||
(defonce ios-utils (when (native-ios?) (registerPlugin "Utils")))
|
||||
(defonce android-utils (when (native-android?) (registerPlugin "Utils")))
|
||||
|
||||
(defonce ios-content-size-listener nil)
|
||||
|
||||
@@ -71,6 +79,21 @@
|
||||
(.setInterfaceStyle ^js ios-utils (clj->js {:mode mode
|
||||
:system system?})))))
|
||||
|
||||
(defn set-android-interface-style!
|
||||
[mode system?]
|
||||
(when (native-android?)
|
||||
(p/do!
|
||||
(.setInterfaceStyle ^js android-utils (clj->js {:mode mode
|
||||
:system system?})))))
|
||||
|
||||
(defn set-native-interface-style!
|
||||
"Sync native light/dark/system appearance with Logseq theme mode."
|
||||
[mode system?]
|
||||
(cond
|
||||
(native-ios?) (set-ios-interface-style! mode system?)
|
||||
(native-android?) (set-android-interface-style! mode system?)
|
||||
:else nil))
|
||||
|
||||
(defn get-idevice-model
|
||||
[]
|
||||
(when (native-ios?)
|
||||
@@ -158,11 +181,11 @@
|
||||
accessibility (assoc :accessibility accessibility))]
|
||||
(cond
|
||||
(not title) (p/rejected (js/Error. "title is required"))
|
||||
(native-ios?) (.alert ^js ui-local (clj->js payload))
|
||||
(native-platform?) (.alert ^js ui-local (clj->js payload))
|
||||
:else (p/resolved nil))))
|
||||
|
||||
(defn hide-alert []
|
||||
(if (native-ios?)
|
||||
(if (native-platform?)
|
||||
(.hideAlert ^js ui-local)
|
||||
(p/resolved nil)))
|
||||
|
||||
|
||||
@@ -1430,8 +1430,8 @@ Similar to re-frame subscriptions"
|
||||
(if (= mode "light")
|
||||
(util/set-theme-light)
|
||||
(util/set-theme-dark)))
|
||||
(when (mobile-util/native-ios?)
|
||||
(mobile-util/set-ios-interface-style! mode system-theme?))
|
||||
(when (mobile-util/native-platform?)
|
||||
(mobile-util/set-native-interface-style! mode system-theme?))
|
||||
(set-state! :ui/theme mode)
|
||||
(storage/set :ui/theme mode)))
|
||||
|
||||
@@ -1475,8 +1475,8 @@ Similar to re-frame subscriptions"
|
||||
[]
|
||||
(let [mode (or (storage/get :ui/theme) "light")
|
||||
system-theme? (storage/get :ui/system-theme?)]
|
||||
(when (mobile-util/native-ios?)
|
||||
(mobile-util/set-ios-interface-style! mode system-theme?))
|
||||
(when (mobile-util/native-platform?)
|
||||
(mobile-util/set-native-interface-style! mode system-theme?))
|
||||
(when (and (not system-theme?)
|
||||
(mobile-util/native-platform?))
|
||||
(if (= mode "light")
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
(ns mobile.bottom-tabs
|
||||
"iOS bottom tabs"
|
||||
"Native bottom tabs"
|
||||
(:require [cljs-bean.core :as bean]
|
||||
[clojure.string :as string]
|
||||
[frontend.handler.editor :as editor-handler]
|
||||
[frontend.handler.route :as route-handler]
|
||||
[frontend.mobile.util :as mobile-util]
|
||||
[frontend.state :as state]
|
||||
[frontend.util :as util]
|
||||
[mobile.navigation :as mobile-nav]
|
||||
[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.
|
||||
;; Capacitor plugin instance (nil if native side hasn't shipped it yet).
|
||||
(def ^js liquid-tabs
|
||||
(.. js/Capacitor -Plugins -LiquidTabsPlugin))
|
||||
|
||||
@@ -22,17 +23,19 @@
|
||||
[{:id \"home\" :title \"Home\" :system-image \"house\" :role \"normal\"}
|
||||
{:id \"search\" :title \"Search\" :system-image \"magnifyingglass\" :role \"search\"}]"
|
||||
[tabs]
|
||||
;; Returns the underlying JS Promise from Capacitor
|
||||
(.configureTabs
|
||||
liquid-tabs
|
||||
(bean/->js {:tabs tabs})))
|
||||
(when liquid-tabs
|
||||
;; Returns the underlying JS Promise from Capacitor
|
||||
(.configureTabs
|
||||
liquid-tabs
|
||||
(bean/->js {:tabs tabs}))))
|
||||
|
||||
(defn select!
|
||||
"Programmatically select a tab by id. Returns a JS Promise."
|
||||
[id]
|
||||
(.selectTab
|
||||
liquid-tabs
|
||||
#js {:id id}))
|
||||
(when liquid-tabs
|
||||
(.selectTab
|
||||
liquid-tabs
|
||||
#js {:id id})))
|
||||
|
||||
(defn update-native-search-results!
|
||||
"Send native search result list to the iOS plugin."
|
||||
@@ -43,17 +46,17 @@
|
||||
(defn add-tab-selected-listener!
|
||||
"Listen to native tab selection.
|
||||
|
||||
`f` receives the tab id string.
|
||||
`f` receives the tab id string and a boolean indicating reselect.
|
||||
Returns the Capacitor listener handle; call `(.remove handle)` to unsubscribe."
|
||||
[f]
|
||||
(when (and (util/capacitor?) liquid-tabs)
|
||||
(.addListener
|
||||
liquid-tabs
|
||||
"tabSelected"
|
||||
(fn [data]
|
||||
;; data is like { id: string }
|
||||
(fn [^js data]
|
||||
;; data is like { id: string, reselected?: boolean }
|
||||
(when-let [id (.-id data)]
|
||||
(f id))))))
|
||||
(f id (boolean (.-reselected data))))))))
|
||||
|
||||
(defn add-search-listener!
|
||||
"Listen to native search query changes from the SwiftUI search tab.
|
||||
@@ -61,54 +64,71 @@
|
||||
`f` receives a query string.
|
||||
Returns the Capacitor listener handle; call `(.remove handle)` to unsubscribe."
|
||||
[f]
|
||||
(.addListener
|
||||
liquid-tabs
|
||||
"searchChanged"
|
||||
(fn [data]
|
||||
;; data is like { query: string }
|
||||
(f (.-query data)))))
|
||||
(when (and (util/capacitor?) liquid-tabs)
|
||||
(.addListener
|
||||
liquid-tabs
|
||||
"searchChanged"
|
||||
(fn [data]
|
||||
;; 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}))))))
|
||||
(when (and (util/capacitor?) liquid-tabs)
|
||||
(.addListener
|
||||
liquid-tabs
|
||||
"openSearchResultBlock"
|
||||
(fn [data]
|
||||
(when-let [id (.-id data)]
|
||||
(when-not (string/blank? id)
|
||||
(route-handler/redirect-to-page! id {:push (mobile-util/native-android?)})))))))
|
||||
|
||||
(defn add-keyboard-hack-listener!
|
||||
"Listen for Backspace or Enter while the invisible keyboard field is focused."
|
||||
[]
|
||||
(.addListener
|
||||
liquid-tabs
|
||||
"keyboardHackKey"
|
||||
(fn [data]
|
||||
;; data is like { key: string }
|
||||
(when-let [k (.-key data)]
|
||||
(case k
|
||||
"backspace"
|
||||
(editor-handler/delete-block-when-zero-pos! nil)
|
||||
"enter"
|
||||
(when-let [input (state/get-input)]
|
||||
(let [value (.-value input)]
|
||||
(when (string/blank? value)
|
||||
(editor-handler/keydown-new-block-handler nil))))
|
||||
nil)))))
|
||||
(when (and (util/capacitor?) liquid-tabs)
|
||||
(.addListener
|
||||
liquid-tabs
|
||||
"keyboardHackKey"
|
||||
(fn [data]
|
||||
;; data is like { key: string }
|
||||
(when-let [k (.-key data)]
|
||||
(case k
|
||||
"backspace"
|
||||
(editor-handler/delete-block-when-zero-pos! nil)
|
||||
"enter"
|
||||
(when-let [input (state/get-input)]
|
||||
(let [value (.-value input)]
|
||||
(when (string/blank? value)
|
||||
(editor-handler/keydown-new-block-handler nil))))
|
||||
nil))))))
|
||||
|
||||
(defonce add-tab-listeners!
|
||||
(do
|
||||
(add-tab-selected-listener!
|
||||
(fn [tab]
|
||||
(mobile-state/set-tab! tab)
|
||||
(when (and (util/capacitor?) liquid-tabs)
|
||||
(let [*current-tab (atom nil)]
|
||||
(add-tab-selected-listener!
|
||||
(fn [tab reselected?]
|
||||
(cond
|
||||
reselected?
|
||||
(do
|
||||
(mobile-nav/pop-to-root! tab)
|
||||
(mobile-state/set-tab! tab)
|
||||
(when (= "home" tab)
|
||||
(util/scroll-to-top false)))
|
||||
|
||||
(when (= "home" tab)
|
||||
(util/scroll-to-top false))))
|
||||
(not= @*current-tab tab)
|
||||
(do
|
||||
(reset! *current-tab tab)
|
||||
(mobile-state/set-tab! tab)
|
||||
(when (= "home" tab)
|
||||
(util/scroll-to-top false))))))
|
||||
|
||||
(add-watch mobile-state/*tab ::select-tab
|
||||
(fn [_ _ _old new]
|
||||
(when (and new (not= @*current-tab new))
|
||||
(reset! *current-tab new)
|
||||
(select! new)))))
|
||||
|
||||
(add-watch mobile-state/*tab ::select-tab
|
||||
(fn [_ _ _old new]
|
||||
(when new (select! new))))
|
||||
(add-search-listener!
|
||||
(fn [q]
|
||||
;; wire up search handler
|
||||
@@ -122,19 +142,25 @@
|
||||
(defn configure
|
||||
[]
|
||||
(configure-tabs
|
||||
[{:id "home"
|
||||
:title "Home"
|
||||
:systemImage "house"
|
||||
:role "normal"}
|
||||
{:id "graphs"
|
||||
:title "Graphs"
|
||||
:systemImage "app.background.dotted"
|
||||
:role "normal"}
|
||||
{:id "capture"
|
||||
:title "Capture"
|
||||
:systemImage "tray"
|
||||
:role "normal"}
|
||||
{:id "go to"
|
||||
:title "Go To"
|
||||
:systemImage "square.stack.3d.down.right"
|
||||
:role "normal"}]))
|
||||
(cond->
|
||||
[{:id "home"
|
||||
:title "Home"
|
||||
:systemImage "house"
|
||||
:role "normal"}
|
||||
{:id "graphs"
|
||||
:title "Graphs"
|
||||
:systemImage "app.background.dotted"
|
||||
:role "normal"}
|
||||
{:id "capture"
|
||||
:title "Capture"
|
||||
:systemImage "tray"
|
||||
:role "normal"}
|
||||
{:id "go to"
|
||||
:title "Go To"
|
||||
:systemImage "square.stack.3d.down.right"
|
||||
:role "normal"}]
|
||||
(mobile-util/native-android?)
|
||||
(conj {:id "search"
|
||||
:title "Search"
|
||||
:systemImage "search"
|
||||
:role "search"}))))
|
||||
|
||||
@@ -53,7 +53,13 @@
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(state/sync-system-theme!)
|
||||
(ui/setup-system-theme-effect!))
|
||||
(ui/setup-system-theme-effect!)
|
||||
(let [handler (fn [^js e]
|
||||
(when (:ui/system-theme? @state/state)
|
||||
(let [is-dark? (boolean (some-> e .-detail .-isDark))]
|
||||
(state/set-theme-mode! (if is-dark? "dark" "light") true))))]
|
||||
(.addEventListener js/window "logseq:native-system-theme-changed" handler)
|
||||
#(.removeEventListener js/window "logseq:native-system-theme-changed" handler)))
|
||||
[])
|
||||
(hooks/use-effect!
|
||||
#(let [^js doc js/document.documentElement
|
||||
@@ -89,7 +95,9 @@
|
||||
{:did-mount (fn [state]
|
||||
(p/do!
|
||||
(editor-handler/quick-add-ensure-new-block-exists!)
|
||||
(editor-handler/quick-add-open-last-block!))
|
||||
(when (mobile-util/native-ios?)
|
||||
;; FIXME: android doesn't open keyboard automatically
|
||||
(editor-handler/quick-add-open-last-block!)))
|
||||
state)}
|
||||
[]
|
||||
(quick-add/quick-add))
|
||||
@@ -118,7 +126,7 @@
|
||||
;; Both are absolutely positioned and stacked; we toggle visibility.
|
||||
[:div.w-full.relative
|
||||
;; Journals scroll container (keep-alive)
|
||||
[:div#app-main-home.pl-3.pr-2.absolute.inset-0
|
||||
[:div#app-main-home.pl-4.pr-3.absolute.inset-0
|
||||
{:class (when-not home? "invisible pointer-events-none")}
|
||||
(home)]
|
||||
|
||||
@@ -134,7 +142,7 @@
|
||||
(use-theme-effects! current-repo theme)
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when (mobile-util/native-ios?)
|
||||
(when (mobile-util/native-platform?)
|
||||
(bottom-tabs/configure))
|
||||
(when-let [element (util/app-scroll-container-node)]
|
||||
(common-handler/listen-to-scroll! element)))
|
||||
|
||||
@@ -23,6 +23,12 @@ html.is-ios {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
html.is-native-android {
|
||||
#app-container-wrapper {
|
||||
padding-top: 56px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
html.is-native-ios body,
|
||||
html.is-native-ios textarea,
|
||||
html.is-native-ios input,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
(ns mobile.components.editor-toolbar
|
||||
"Mobile editor toolbar"
|
||||
(:require [frontend.colors :as colors]
|
||||
[frontend.commands :as commands]
|
||||
(:require [frontend.commands :as commands]
|
||||
[frontend.handler.editor :as editor-handler]
|
||||
[frontend.handler.history :as history]
|
||||
[frontend.mobile.camera :as mobile-camera]
|
||||
@@ -179,12 +178,12 @@
|
||||
native-actions (mapv action->native main)
|
||||
trailing-native (some-> trailing action->native)
|
||||
plugin ^js mobile-util/native-editor-toolbar
|
||||
should-show? (and show? (mobile-util/native-ios?) (some? plugin))]
|
||||
should-show? (and show? (mobile-util/native-platform?) (some? plugin))]
|
||||
(set! (.-current handlers-ref) (action-handlers main trailing))
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when (and (mobile-util/native-ios?) plugin)
|
||||
(when (and (mobile-util/native-platform?) plugin)
|
||||
(let [listener (.addListener plugin "action"
|
||||
(fn [^js e]
|
||||
(when-let [id (.-id e)]
|
||||
@@ -199,12 +198,11 @@
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when (mobile-util/native-ios?)
|
||||
(when (mobile-util/native-platform?)
|
||||
(when should-show?
|
||||
(.present plugin (clj->js {:actions native-actions
|
||||
:trailingAction trailing-native
|
||||
:tintColor (colors/get-accent-color)}))))
|
||||
#(when (and (mobile-util/native-ios?) should-show?)
|
||||
:trailingAction trailing-native}))))
|
||||
#(when (and (mobile-util/native-platform?) should-show?)
|
||||
(.dismiss plugin)))
|
||||
[should-show? native-actions trailing-native])
|
||||
|
||||
@@ -217,5 +215,5 @@
|
||||
show? (and (not code-block?)
|
||||
editing?)
|
||||
actions (toolbar-actions)]
|
||||
(when (mobile-util/native-ios?)
|
||||
(when (mobile-util/native-platform?)
|
||||
(native-toolbar show? actions))))
|
||||
|
||||
@@ -108,11 +108,13 @@
|
||||
{:default-height false}))
|
||||
|
||||
(defn- register-native-top-bar-events! []
|
||||
(when (and (mobile-util/native-ios?)
|
||||
(when (and (mobile-util/native-platform?)
|
||||
mobile-util/native-top-bar
|
||||
(not @native-top-bar-listener?))
|
||||
(.addListener ^js mobile-util/native-top-bar "buttonTapped"
|
||||
(fn [^js e]
|
||||
(case (.-id e)
|
||||
"back" (js/history.back)
|
||||
"title" (open-graph-switch!)
|
||||
"calendar" (open-journal-calendar!)
|
||||
"capture" (do
|
||||
@@ -142,8 +144,9 @@
|
||||
|
||||
(defn- configure-native-top-bar!
|
||||
[repo {:keys [tab title route-name route-view sync-color favorited?]}]
|
||||
(when (mobile-util/native-ios?)
|
||||
(let [hidden? (= tab "search")
|
||||
(when (and (mobile-util/native-platform?)
|
||||
mobile-util/native-top-bar)
|
||||
(let [hidden? (and (mobile-util/native-ios?) (= tab "search"))
|
||||
rtc-indicator? (and repo
|
||||
(ldb/get-graph-rtc-uuid (db/get-db))
|
||||
(user-handler/logged-in?))
|
||||
@@ -153,6 +156,7 @@
|
||||
(assoc :title title))
|
||||
page? (= route-name :page)
|
||||
left-buttons (cond
|
||||
page? [{:id "back" :systemIcon "chevron.backward"}]
|
||||
(and (= tab "home") (nil? route-view))
|
||||
[(conj {:id "calendar" :systemIcon "calendar"})]
|
||||
(and (= tab "capture") (nil? route-view))
|
||||
@@ -178,6 +182,9 @@
|
||||
[{:id "capture" :systemIcon "paperplane"}]
|
||||
|
||||
:else nil)
|
||||
[left-buttons right-buttons] (if (mobile-util/native-android?)
|
||||
[(reverse left-buttons) (reverse right-buttons)]
|
||||
[left-buttons right-buttons])
|
||||
header (cond-> base
|
||||
left-buttons (assoc :leftButtons left-buttons)
|
||||
right-buttons (assoc :rightButtons right-buttons)
|
||||
@@ -208,7 +215,8 @@
|
||||
"#CA8A04")]
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when (mobile-util/native-ios?)
|
||||
(when (and (mobile-util/native-platform?)
|
||||
mobile-util/native-top-bar)
|
||||
(register-native-top-bar-events!)
|
||||
(p/let [block (when (= route-name :page)
|
||||
(let [id (get-in route-match [:parameters :path :name])]
|
||||
|
||||
@@ -41,8 +41,7 @@
|
||||
|
||||
(defn- handle-native-sheet-state!
|
||||
[^js data]
|
||||
(let [;; presenting? (.-presenting data)
|
||||
dismissing? (.-dismissing data)]
|
||||
(let [dismissing? (.-dismissing data)]
|
||||
(cond
|
||||
dismissing?
|
||||
(when (some? @mobile-state/*popup-data)
|
||||
@@ -50,17 +49,16 @@
|
||||
(state/pub-event! [:mobile/clear-edit])
|
||||
(mobile-state/set-popup! nil)
|
||||
(reset! *last-popup-data nil)
|
||||
(when (mobile-util/native-ios?)
|
||||
(let [plugin ^js mobile-util/native-editor-toolbar]
|
||||
(.dismiss plugin)))))
|
||||
(when-let [plugin ^js mobile-util/native-editor-toolbar]
|
||||
(.dismiss plugin))))
|
||||
|
||||
:else
|
||||
nil)))
|
||||
|
||||
(defonce native-sheet-listener
|
||||
(when (mobile-util/native-ios?)
|
||||
(when-let [^js plugin mobile-util/native-bottom-sheet]
|
||||
(.addListener plugin "state" handle-native-sheet-state!))))
|
||||
(when-let [^js plugin (when (mobile-util/native-platform?)
|
||||
mobile-util/native-bottom-sheet)]
|
||||
(.addListener plugin "state" handle-native-sheet-state!)))
|
||||
|
||||
(defn- wrap-calc-commands-popup-side
|
||||
[pos opts]
|
||||
@@ -98,7 +96,7 @@
|
||||
:else
|
||||
(when content-fn
|
||||
(reset! *last-popup? true)
|
||||
(when (mobile-util/native-ios?)
|
||||
(when-let [_plugin ^js mobile-util/native-bottom-sheet]
|
||||
(let [data {:open? true
|
||||
:content-fn content-fn
|
||||
:opts opts}]
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
(defn- dismiss-action-bar!
|
||||
[]
|
||||
(.dismiss ^js mobile-util/native-selection-action-bar))
|
||||
(when-let [plugin ^js mobile-util/native-selection-action-bar]
|
||||
(.dismiss plugin)))
|
||||
|
||||
(defn close-selection-bar!
|
||||
[]
|
||||
@@ -81,7 +82,7 @@
|
||||
|
||||
(hooks/use-effect!
|
||||
(fn []
|
||||
(when (and (mobile-util/native-ios?)
|
||||
(when (and (mobile-util/native-platform?)
|
||||
mobile-util/native-selection-action-bar)
|
||||
(let [listener (.addListener ^js mobile-util/native-selection-action-bar
|
||||
"action"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
(:require ["@capacitor/app" :refer [^js App]]
|
||||
["@capacitor/keyboard" :refer [^js Keyboard]]
|
||||
["@capacitor/network" :refer [^js Network]]
|
||||
["@capgo/capacitor-navigation-bar" :refer [^js NavigationBar]]
|
||||
[clojure.string :as string]
|
||||
[frontend.handler.editor :as editor-handler]
|
||||
[frontend.mobile.flows :as mobile-flows]
|
||||
@@ -46,8 +45,6 @@
|
||||
(defn- android-init!
|
||||
"Initialize Android-specified event listeners"
|
||||
[]
|
||||
(js/setTimeout
|
||||
#(.setNavigationBarColor NavigationBar #js {:color "transparent"}) 128)
|
||||
(.addListener App "backButton"
|
||||
(fn []
|
||||
(when (false?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(ns mobile.navigation
|
||||
"Native navigation bridge for mobile (iOS)."
|
||||
"Native navigation bridge for mobile."
|
||||
(:require [clojure.string :as string]
|
||||
[frontend.handler.route :as route-handler]
|
||||
[frontend.mobile.util :as mobile-util]
|
||||
@@ -186,7 +186,7 @@
|
||||
path (if (string/blank? path) "/" path)]
|
||||
(set-current-stack! stack)
|
||||
(remember-route! stack navigation-type route path route-match)
|
||||
(when (and (mobile-util/native-ios?)
|
||||
(when (and (mobile-util/native-platform?)
|
||||
mobile-util/ui-local)
|
||||
(let [payload (cond-> {:navigationType navigation-type
|
||||
:push push?
|
||||
@@ -238,6 +238,12 @@
|
||||
(let [stack (current-stack)
|
||||
{:keys [history]} (get @stack-history stack)
|
||||
history (vec history)]
|
||||
;; back to search root
|
||||
(when (and
|
||||
(mobile-util/native-android?)
|
||||
(= stack "search")
|
||||
(= (count history) 2))
|
||||
(.showSearchUiNative ^js (.. js/Capacitor -Plugins -LiquidTabsPlugin)))
|
||||
(when (>= (count history) 1)
|
||||
(let [root-history? (= (count history) 1)
|
||||
new-history (if root-history?
|
||||
@@ -259,6 +265,33 @@
|
||||
|
||||
(route-handler/set-route-match! route-match)))))
|
||||
|
||||
(defn pop-to-root!
|
||||
"Pop current or given stack back to its root entry and notify navigation."
|
||||
([] (pop-to-root! (current-stack)))
|
||||
([stack]
|
||||
(when stack
|
||||
(let [{:keys [history]} (get @stack-history stack)
|
||||
root (or (first history) (stack-defaults stack))
|
||||
{:keys [route route-match path]} root
|
||||
route-match (or route-match (:route-match (stack-defaults stack)))
|
||||
path (or path (current-path))
|
||||
route (or route {:to (get-in route-match [:data :name])
|
||||
:path-params (get-in route-match [:parameters :path])
|
||||
:query-params (get-in route-match [:parameters :query])})]
|
||||
(swap! stack-history assoc stack {:history [root]})
|
||||
(set-current-stack! stack)
|
||||
;; Use original replace-state to avoid recording a push intent.
|
||||
(orig-replace-state (get-in route-match [:data :name])
|
||||
(get-in route-match [:parameters :path])
|
||||
(get-in route-match [:parameters :query]))
|
||||
(route-handler/set-route-match! route-match)
|
||||
(notify-route-change!
|
||||
{:route route
|
||||
:route-match route-match
|
||||
:path path
|
||||
:stack stack
|
||||
:push false})))))
|
||||
|
||||
(defn ^:export install-native-bridge!
|
||||
[]
|
||||
(set! (.-LogseqNative js/window)
|
||||
|
||||
Reference in New Issue
Block a user