Просмотр исходного кода

feat: 允许字典打开网页,修复一些问题

HapeLee 2 месяцев назад
Родитель
Сommit
67cff9a5e1

+ 2 - 0
app/src/main/java/io/legado/app/ui/dict/DictContract.kt

@@ -34,8 +34,10 @@ sealed interface DictEmptyReason {
 sealed interface DictIntent {
     data class Load(val word: String) : DictIntent
     data class SelectRule(val index: Int) : DictIntent
+    data object OpenSelectedRuleInWebView : DictIntent
 }
 
 sealed interface DictEffect {
     data class ShowToast(val message: String) : DictEffect
+    data class OpenWebPage(val title: String, val url: String) : DictEffect
 }

+ 117 - 12
app/src/main/java/io/legado/app/ui/dict/DictSheet.kt

@@ -1,24 +1,38 @@
 package io.legado.app.ui.dict
 
+import android.annotation.SuppressLint
+import android.net.Uri
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.spring
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.OpenInBrowser
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -28,14 +42,17 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
+import io.legado.app.ui.rss.read.VisibleWebViewCompose
 import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.pager.pagerHeight
 import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.HtmlContent
-import kotlinx.coroutines.flow.Flow
+import io.legado.app.utils.openUrl
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.launch
@@ -49,6 +66,7 @@ fun DictSheet(
     viewModel: DictViewModel = koinViewModel(),
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    var webPage by remember { mutableStateOf<DictWebPage?>(null) }
 
     LaunchedEffect(show, word) {
         if (show) {
@@ -56,39 +74,50 @@ fun DictSheet(
         }
     }
 
+    LaunchedEffect(viewModel.effects) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is DictEffect.ShowToast -> Unit
+                is DictEffect.OpenWebPage -> webPage = DictWebPage(effect.title, effect.url)
+            }
+        }
+    }
+
     AppModalBottomSheet(
-        show = show,
+        show = show && webPage == null,
         onDismissRequest = onDismissRequest,
         title = word,
+        endAction = {
+            MediumTonalButton(
+                onClick = { viewModel.onIntent(DictIntent.OpenSelectedRuleInWebView) },
+                icon = Icons.Default.OpenInBrowser,
+                contentDescription = stringResource(R.string.source_tab_web_view),
+            )
+        },
         contentPaddingEnabled = false,
         animateContentSize = false,
     ) {
         DictSheetContent(
             state = state,
             onIntent = viewModel::onIntent,
-            effects = viewModel.effects,
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(bottom = 16.dp),
         )
     }
+
+    DictWebSheet(
+        page = webPage,
+        onDismissRequest = { webPage = null },
+    )
 }
 
 @Composable
 private fun DictSheetContent(
     state: DictUiState,
     onIntent: (DictIntent) -> Unit,
-    effects: Flow<DictEffect>,
     modifier: Modifier = Modifier,
 ) {
-    LaunchedEffect(effects) {
-        effects.collect { effect ->
-            when (effect) {
-                is DictEffect.ShowToast -> Unit
-            }
-        }
-    }
-
     DictContent(
         state = state,
         onIntent = onIntent,
@@ -96,6 +125,82 @@ private fun DictSheetContent(
     )
 }
 
+private data class DictWebPage(
+    val title: String,
+    val url: String,
+)
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+private fun DictWebSheet(
+    page: DictWebPage?,
+    onDismissRequest: () -> Unit,
+) {
+    page ?: return
+    val context = androidx.compose.ui.platform.LocalContext.current
+    var progress by remember(page.url) { mutableIntStateOf(0) }
+
+    AppModalBottomSheet(
+        show = true,
+        onDismissRequest = onDismissRequest,
+        title = page.title,
+        contentPaddingEnabled = false,
+        sheetGesturesEnabled = false,
+    ) {
+        Box(
+            Modifier
+                .fillMaxWidth()
+                .heightIn(min = 520.dp)
+        ) {
+            VisibleWebViewCompose(
+                modifier = Modifier.fillMaxSize(),
+                onCreated = { webView ->
+                    webView.apply {
+                        setBackgroundColor(android.graphics.Color.WHITE)
+                        settings.apply {
+                            mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+                            domStorageEnabled = true
+                            useWideViewPort = true
+                            loadWithOverviewMode = true
+                            builtInZoomControls = true
+                            displayZoomControls = false
+                            javaScriptEnabled = true
+                        }
+                        webViewClient = object : WebViewClient() {
+                            override fun shouldOverrideUrlLoading(
+                                view: WebView,
+                                request: WebResourceRequest,
+                            ): Boolean = handleUrl(request.url)
+
+                            @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
+                            override fun shouldOverrideUrlLoading(
+                                view: WebView,
+                                url: String
+                            ): Boolean =
+                                handleUrl(Uri.parse(url))
+
+                            private fun handleUrl(uri: Uri): Boolean {
+                                if (uri.scheme == "http" || uri.scheme == "https") return false
+                                context.openUrl(uri)
+                                return true
+                            }
+                        }
+                        webChromeClient = object : WebChromeClient() {
+                            override fun onProgressChanged(view: WebView?, newProgress: Int) {
+                                progress = newProgress
+                            }
+                        }
+                        loadUrl(page.url)
+                    }
+                },
+            )
+            if (progress in 0..99) {
+                AppLinearProgressIndicator(Modifier.fillMaxWidth())
+            }
+        }
+    }
+}
+
 @Composable
 private fun DictContent(
     state: DictUiState,

+ 24 - 0
app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.repository.DictRuleRepository
+import io.legado.app.model.analyzeRule.AnalyzeUrl
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
@@ -33,6 +34,7 @@ class DictViewModel(
         when (intent) {
             is DictIntent.Load -> load(intent.word)
             is DictIntent.SelectRule -> selectRule(intent.index)
+            DictIntent.OpenSelectedRuleInWebView -> openSelectedRuleInWebView()
         }
     }
 
@@ -140,6 +142,28 @@ class DictViewModel(
         }
     }
 
+    private fun openSelectedRuleInWebView() {
+        val rule = dictRules.getOrNull(_uiState.value.selectedIndex) ?: return
+        val word = _uiState.value.word
+        if (word.isBlank()) return
+
+        viewModelScope.launch {
+            val url = try {
+                withContext(Dispatchers.IO) {
+                    AnalyzeUrl(rule.urlRule, key = word, coroutineContext = coroutineContext).url
+                }
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Throwable) {
+                _effects.emit(DictEffect.ShowToast(error.localizedMessage ?: "ERROR"))
+                return@launch
+            }
+            if (url.isNotBlank()) {
+                _effects.emit(DictEffect.OpenWebPage(rule.name, url))
+            }
+        }
+    }
+
     override fun onCleared() {
         dictJob?.cancel()
         super.onCleared()

+ 5 - 1
app/src/main/java/io/legado/app/ui/widget/components/text/HtmlContent.kt

@@ -499,7 +499,11 @@ internal object HtmlParser {
             "ul", "ol" -> parseList(element, style, inheritedLink, paragraphs)
             "table" -> element.select("tr").forEach { row ->
                 val content = mutableListOf<HtmlInline>()
-                row.select(":scope > th, :scope > td").forEachIndexed { index, cell ->
+                // jsoup 1.16.2 (intentionally pinned for rule compatibility) does not support
+                // the CSS :scope pseudo-selector. Reading direct children also expresses the
+                // intended table-cell semantics without relying on selector support.
+                row.children().filter { it.normalName() == "th" || it.normalName() == "td" }
+                    .forEachIndexed { index, cell ->
                     if (index > 0) appendText(content, "  ", style, inheritedLink)
                     cell.childNodes().forEach { parseInline(it, style, inheritedLink, content) }
                 }

+ 10 - 0
app/src/test/java/io/legado/app/ui/widget/components/text/HtmlParserTest.kt

@@ -69,4 +69,14 @@ class HtmlParserTest {
             doc.paragraphs.flatMap { it.content }.filterIsInstance<HtmlInline.Image>().single()
         assertEquals(null, image.click)
     }
+
+    @Test
+    fun tableWithDirectCellsIsParsedWithoutScopeSelector() {
+        val doc = HtmlParser.parse("<table><tr><th>Word</th><td>释义</td></tr></table>")
+
+        val text = doc.paragraphs.single().content
+            .filterIsInstance<HtmlInline.Text>()
+            .joinToString("") { it.value }
+        assertEquals("Word  释义", text)
+    }
 }