Add HTML reader from Feeder (#65)
* Add HTML reader from Feeder Thanks to the Feeder! * Apply ScrollBar * Add share menu * Update README
This commit is contained in:
parent
b7813d45f4
commit
b31e7eb98e
|
@ -90,7 +90,8 @@
|
||||||
- [Readability4J](https://github.com/dankito/Readability4J): [Apache License 2.0](https://github.com/dankito/Readability4J/blob/master/LICENSE)
|
- [Readability4J](https://github.com/dankito/Readability4J): [Apache License 2.0](https://github.com/dankito/Readability4J/blob/master/LICENSE)
|
||||||
- [opml-parser](https://github.com/mdewilde/opml-parser): [Apache License 2.0](https://github.com/mdewilde/opml-parser/blob/master/LICENSE)
|
- [opml-parser](https://github.com/mdewilde/opml-parser): [Apache License 2.0](https://github.com/mdewilde/opml-parser/blob/master/LICENSE)
|
||||||
- [compose-html](https://github.com/ireward/compose-html): [Apache License 2.0](https://github.com/ireward/compose-html/blob/main/LICENSE.txt)
|
- [compose-html](https://github.com/ireward/compose-html): [Apache License 2.0](https://github.com/ireward/compose-html/blob/main/LICENSE.txt)
|
||||||
- (待完善)
|
- [Rome](https://github.com/rometools/rome): [Apache License 2.0](https://github.com/rometools/rome/blob/master/LICENSE)
|
||||||
|
- [Feeder](https://gitlab.com/spacecowboy/Feeder): [GPL v3.0](https://gitlab.com/spacecowboy/Feeder/-/blob/master/LICENSE)
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|
|
@ -90,7 +90,8 @@ The following are the progress made so far and the goals to be worked on in the
|
||||||
- [Readability4J](https://github.com/dankito/Readability4J): [Apache License 2.0](https://github.com/dankito/Readability4J/blob/master/LICENSE)
|
- [Readability4J](https://github.com/dankito/Readability4J): [Apache License 2.0](https://github.com/dankito/Readability4J/blob/master/LICENSE)
|
||||||
- [opml-parser](https://github.com/mdewilde/opml-parser): [Apache License 2.0](https://github.com/mdewilde/opml-parser/blob/master/LICENSE)
|
- [opml-parser](https://github.com/mdewilde/opml-parser): [Apache License 2.0](https://github.com/mdewilde/opml-parser/blob/master/LICENSE)
|
||||||
- [compose-html](https://github.com/ireward/compose-html): [Apache License 2.0](https://github.com/ireward/compose-html/blob/main/LICENSE.txt)
|
- [compose-html](https://github.com/ireward/compose-html): [Apache License 2.0](https://github.com/ireward/compose-html/blob/main/LICENSE.txt)
|
||||||
- (To be improved)
|
- [Rome](https://github.com/rometools/rome): [Apache License 2.0](https://github.com/rometools/rome/blob/master/LICENSE)
|
||||||
|
- [Feeder](https://gitlab.com/spacecowboy/Feeder): [GPL v3.0](https://gitlab.com/spacecowboy/Feeder/-/blob/master/LICENSE)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
@ -101,6 +101,7 @@ dependencies {
|
||||||
// https://coil-kt.github.io/coil/changelog/
|
// https://coil-kt.github.io/coil/changelog/
|
||||||
implementation("io.coil-kt:coil-compose:$coil")
|
implementation("io.coil-kt:coil-compose:$coil")
|
||||||
implementation("io.coil-kt:coil-svg:$coil")
|
implementation("io.coil-kt:coil-svg:$coil")
|
||||||
|
implementation("io.coil-kt:coil-gif:$coil")
|
||||||
|
|
||||||
// https://square.github.io/okhttp/changelogs/changelog/
|
// https://square.github.io/okhttp/changelogs/changelog/
|
||||||
implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.6"
|
implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.6"
|
||||||
|
|
|
@ -1,10 +1,24 @@
|
||||||
package me.ash.reader
|
package me.ash.reader
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.ColorDrawable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Build
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import coil.ComponentRegistry
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.decode.DataSource
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
|
import coil.decode.SvgDecoder
|
||||||
|
import coil.disk.DiskCache
|
||||||
|
import coil.memory.MemoryCache
|
||||||
|
import coil.request.*
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -18,7 +32,7 @@ import me.ash.reader.ui.ext.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class App : Application(), Configuration.Provider {
|
class App : Application(), Configuration.Provider, ImageLoader {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var readerDatabase: ReaderDatabase
|
lateinit var readerDatabase: ReaderDatabase
|
||||||
|
|
||||||
|
@ -108,4 +122,58 @@ class App : Application(), Configuration.Provider {
|
||||||
.setWorkerFactory(workerFactory)
|
.setWorkerFactory(workerFactory)
|
||||||
.setMinimumLoggingLevel(android.util.Log.DEBUG)
|
.setMinimumLoggingLevel(android.util.Log.DEBUG)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
override val components: ComponentRegistry
|
||||||
|
get() = ComponentRegistry.Builder()
|
||||||
|
.add(SvgDecoder.Factory())
|
||||||
|
.add(
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
ImageDecoderDecoder.Factory()
|
||||||
|
} else {
|
||||||
|
GifDecoder.Factory()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
override val defaults: DefaultRequestOptions
|
||||||
|
get() = DefaultRequestOptions()
|
||||||
|
override val diskCache: DiskCache
|
||||||
|
get() = DiskCache.Builder()
|
||||||
|
.directory(cacheDir.resolve("images"))
|
||||||
|
.maxSizePercent(0.02)
|
||||||
|
.build()
|
||||||
|
override val memoryCache: MemoryCache
|
||||||
|
get() = MemoryCache.Builder(this)
|
||||||
|
.maxSizePercent(0.25)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun enqueue(request: ImageRequest): Disposable {
|
||||||
|
// Always call onStart before onSuccess.
|
||||||
|
request.target?.onStart(request.placeholder)
|
||||||
|
val result = ColorDrawable(Color.BLACK)
|
||||||
|
request.target?.onSuccess(result)
|
||||||
|
return object : Disposable {
|
||||||
|
override val job = CompletableDeferred(newResult(request, result))
|
||||||
|
override val isDisposed get() = true
|
||||||
|
override fun dispose() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun execute(request: ImageRequest): ImageResult {
|
||||||
|
return newResult(request, ColorDrawable(Color.BLACK))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newBuilder(): ImageLoader.Builder {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shutdown() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newResult(request: ImageRequest, drawable: Drawable): SuccessResult {
|
||||||
|
return SuccessResult(
|
||||||
|
drawable = drawable,
|
||||||
|
request = request,
|
||||||
|
dataSource = DataSource.MEMORY_CACHE
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,30 +1,19 @@
|
||||||
package me.ash.reader
|
package me.ash.reader
|
||||||
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.drawable.ColorDrawable
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.profileinstaller.ProfileInstallerInitializer
|
import androidx.profileinstaller.ProfileInstallerInitializer
|
||||||
import coil.ComponentRegistry
|
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.decode.DataSource
|
|
||||||
import coil.decode.SvgDecoder
|
|
||||||
import coil.disk.DiskCache
|
|
||||||
import coil.memory.MemoryCache
|
|
||||||
import coil.request.*
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import me.ash.reader.data.preference.LanguagesPreference
|
import me.ash.reader.data.preference.LanguagesPreference
|
||||||
import me.ash.reader.data.preference.SettingsProvider
|
import me.ash.reader.data.preference.SettingsProvider
|
||||||
import me.ash.reader.ui.ext.languages
|
import me.ash.reader.ui.ext.languages
|
||||||
import me.ash.reader.ui.page.common.HomeEntry
|
import me.ash.reader.ui.page.common.HomeEntry
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity(), ImageLoader {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -43,49 +32,4 @@ class MainActivity : ComponentActivity(), ImageLoader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val components: ComponentRegistry
|
|
||||||
get() = ComponentRegistry.Builder().add(SvgDecoder.Factory()).build()
|
|
||||||
override val defaults: DefaultRequestOptions
|
|
||||||
get() = DefaultRequestOptions()
|
|
||||||
override val diskCache: DiskCache
|
|
||||||
get() = DiskCache.Builder()
|
|
||||||
.directory(this.cacheDir.resolve("images"))
|
|
||||||
.maxSizePercent(0.02)
|
|
||||||
.build()
|
|
||||||
override val memoryCache: MemoryCache
|
|
||||||
get() = MemoryCache.Builder(this)
|
|
||||||
.maxSizePercent(0.25)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override fun enqueue(request: ImageRequest): Disposable {
|
|
||||||
// Always call onStart before onSuccess.
|
|
||||||
request.target?.onStart(request.placeholder)
|
|
||||||
val result = ColorDrawable(Color.BLACK)
|
|
||||||
request.target?.onSuccess(result)
|
|
||||||
return object : Disposable {
|
|
||||||
override val job = CompletableDeferred(newResult(request, result))
|
|
||||||
override val isDisposed get() = true
|
|
||||||
override fun dispose() {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun execute(request: ImageRequest): ImageResult {
|
|
||||||
return newResult(request, ColorDrawable(Color.BLACK))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun newBuilder(): ImageLoader.Builder {
|
|
||||||
throw UnsupportedOperationException()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun shutdown() {
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun newResult(request: ImageRequest, drawable: Drawable): SuccessResult {
|
|
||||||
return SuccessResult(
|
|
||||||
drawable = drawable,
|
|
||||||
request = request,
|
|
||||||
dataSource = DataSource.MEMORY_CACHE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
123
app/src/main/java/me/ash/reader/ui/component/AsyncImage.kt
Normal file
123
app/src/main/java/me/ash/reader/ui/component/AsyncImage.kt
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
package me.ash.reader.ui.component
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.graphics.DefaultAlpha
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import coil.imageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.size.Precision
|
||||||
|
import coil.size.Scale
|
||||||
|
import coil.size.Size
|
||||||
|
import me.ash.reader.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AsyncImage(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
data: Any? = null,
|
||||||
|
size: Size = Size.ORIGINAL,
|
||||||
|
scale: Scale = Scale.FIT,
|
||||||
|
precision: Precision = Precision.AUTOMATIC,
|
||||||
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
contentDescription: String = "",
|
||||||
|
@DrawableRes placeholder: Int? = R.drawable.ic_hourglass_empty_black_24dp,
|
||||||
|
@DrawableRes error: Int? = R.drawable.ic_broken_image_black_24dp,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
coil.compose.AsyncImage(
|
||||||
|
modifier = modifier,
|
||||||
|
model = ImageRequest
|
||||||
|
.Builder(context)
|
||||||
|
.data(data)
|
||||||
|
.crossfade(true)
|
||||||
|
.scale(scale)
|
||||||
|
.precision(precision)
|
||||||
|
.size(size)
|
||||||
|
.build(),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = contentScale,
|
||||||
|
imageLoader = context.imageLoader,
|
||||||
|
placeholder = placeholder?.let {
|
||||||
|
forwardingPainter(
|
||||||
|
painter = painterResource(it),
|
||||||
|
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
|
alpha = 0.5f,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
error = error?.let {
|
||||||
|
forwardingPainter(
|
||||||
|
painter = painterResource(it),
|
||||||
|
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onError),
|
||||||
|
alpha = 0.5f
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From: https://gist.github.com/colinrtwhite/c2966e0b8584b4cdf0a5b05786b20ae1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and return a new [Painter] that wraps [painter] with its [alpha], [colorFilter], or [onDraw] overwritten.
|
||||||
|
*/
|
||||||
|
fun forwardingPainter(
|
||||||
|
painter: Painter,
|
||||||
|
alpha: Float = DefaultAlpha,
|
||||||
|
colorFilter: ColorFilter? = null,
|
||||||
|
onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw,
|
||||||
|
): Painter = ForwardingPainter(painter, alpha, colorFilter, onDraw)
|
||||||
|
|
||||||
|
data class ForwardingDrawInfo(
|
||||||
|
val painter: Painter,
|
||||||
|
val alpha: Float,
|
||||||
|
val colorFilter: ColorFilter?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class ForwardingPainter(
|
||||||
|
private val painter: Painter,
|
||||||
|
private var alpha: Float,
|
||||||
|
private var colorFilter: ColorFilter?,
|
||||||
|
private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit,
|
||||||
|
) : Painter() {
|
||||||
|
|
||||||
|
private var info = newInfo()
|
||||||
|
|
||||||
|
override val intrinsicSize get() = painter.intrinsicSize
|
||||||
|
|
||||||
|
override fun applyAlpha(alpha: Float): Boolean {
|
||||||
|
if (alpha == DefaultAlpha) {
|
||||||
|
this.alpha = alpha
|
||||||
|
this.info = newInfo()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
|
||||||
|
if (colorFilter == null) {
|
||||||
|
this.colorFilter = colorFilter
|
||||||
|
this.info = newInfo()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun DrawScope.onDraw() = onDraw(info)
|
||||||
|
|
||||||
|
private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info ->
|
||||||
|
with(info.painter) {
|
||||||
|
draw(
|
||||||
|
androidx.compose.ui.geometry.Size(size.width, size.height),
|
||||||
|
info.alpha,
|
||||||
|
info.colorFilter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,11 +7,7 @@ import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.imageLoader
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import com.caverock.androidsvg.SVG
|
import com.caverock.androidsvg.SVG
|
||||||
import me.ash.reader.data.preference.LocalDarkTheme
|
import me.ash.reader.data.preference.LocalDarkTheme
|
||||||
import me.ash.reader.ui.svg.parseDynamicColor
|
import me.ash.reader.ui.svg.parseDynamicColor
|
||||||
|
@ -23,7 +19,6 @@ fun DynamicSVGImage(
|
||||||
svgImageString: String,
|
svgImageString: String,
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val useDarkTheme = LocalDarkTheme.current.isDarkTheme()
|
val useDarkTheme = LocalDarkTheme.current.isDarkTheme()
|
||||||
val tonalPalettes = LocalTonalPalettes.current
|
val tonalPalettes = LocalTonalPalettes.current
|
||||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
@ -48,11 +43,9 @@ fun DynamicSVGImage(
|
||||||
Crossfade(targetState = pic) {
|
Crossfade(targetState = pic) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
model = ImageRequest.Builder(context)
|
data = it,
|
||||||
.data(it)
|
placeholder = null,
|
||||||
.crossfade(true)
|
error = null,
|
||||||
.build(),
|
|
||||||
imageLoader = context.imageLoader,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,191 @@
|
||||||
|
/*
|
||||||
|
* Feeder: Android RSS reader app
|
||||||
|
* https://gitlab.com/spacecowboy/Feeder
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Jonas Kalderstam
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.ash.reader.ui.component.reader
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
|
||||||
|
class AnnotatedParagraphStringBuilder {
|
||||||
|
// Private for a reason
|
||||||
|
private val builder: AnnotatedString.Builder = AnnotatedString.Builder()
|
||||||
|
|
||||||
|
private val poppedComposableStyles = mutableListOf<ComposableStyleWithStartEnd>()
|
||||||
|
val composableStyles = mutableListOf<ComposableStyleWithStartEnd>()
|
||||||
|
val lastTwoChars: MutableList<Char> = mutableListOf()
|
||||||
|
|
||||||
|
val length: Int
|
||||||
|
get() = builder.length
|
||||||
|
|
||||||
|
val endsWithWhitespace: Boolean
|
||||||
|
get() {
|
||||||
|
if (lastTwoChars.isEmpty()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
lastTwoChars.peekLatest()?.let { latest ->
|
||||||
|
// Non-breaking space (160) is not caught by trim or whitespace identification
|
||||||
|
if (latest.isWhitespace() || latest.code == 160) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pushStyle(style: SpanStyle): Int =
|
||||||
|
builder.pushStyle(style = style)
|
||||||
|
|
||||||
|
fun pop(index: Int) =
|
||||||
|
builder.pop(index)
|
||||||
|
|
||||||
|
fun pushStringAnnotation(tag: String, annotation: String): Int =
|
||||||
|
builder.pushStringAnnotation(tag = tag, annotation = annotation)
|
||||||
|
|
||||||
|
fun pushComposableStyle(
|
||||||
|
style: @Composable () -> SpanStyle
|
||||||
|
): Int {
|
||||||
|
composableStyles.add(
|
||||||
|
ComposableStyleWithStartEnd(
|
||||||
|
style = style,
|
||||||
|
start = builder.length
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return composableStyles.lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
fun popComposableStyle(
|
||||||
|
index: Int
|
||||||
|
) {
|
||||||
|
poppedComposableStyles.add(
|
||||||
|
composableStyles.removeAt(index).copy(end = builder.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun append(text: String) {
|
||||||
|
if (text.count() >= 2) {
|
||||||
|
lastTwoChars.pushMaxTwo(text.secondToLast())
|
||||||
|
}
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
lastTwoChars.pushMaxTwo(text.last())
|
||||||
|
}
|
||||||
|
builder.append(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun append(char: Char) {
|
||||||
|
lastTwoChars.pushMaxTwo(char)
|
||||||
|
builder.append(char)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun toAnnotatedString(): AnnotatedString {
|
||||||
|
for (composableStyle in poppedComposableStyles) {
|
||||||
|
builder.addStyle(
|
||||||
|
style = composableStyle.style(),
|
||||||
|
start = composableStyle.start,
|
||||||
|
end = composableStyle.end
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (composableStyle in composableStyles) {
|
||||||
|
builder.addStyle(
|
||||||
|
style = composableStyle.style(),
|
||||||
|
start = composableStyle.start,
|
||||||
|
end = builder.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return builder.toAnnotatedString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AnnotatedParagraphStringBuilder.isEmpty() = lastTwoChars.isEmpty()
|
||||||
|
fun AnnotatedParagraphStringBuilder.isNotEmpty() = lastTwoChars.isNotEmpty()
|
||||||
|
|
||||||
|
fun AnnotatedParagraphStringBuilder.ensureDoubleNewline() {
|
||||||
|
when {
|
||||||
|
lastTwoChars.isEmpty() -> {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
length == 1 && lastTwoChars.peekLatest()?.isWhitespace() == true -> {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
length == 2 &&
|
||||||
|
lastTwoChars.peekLatest()?.isWhitespace() == true &&
|
||||||
|
lastTwoChars.peekSecondLatest()?.isWhitespace() == true -> {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
lastTwoChars.peekLatest() == '\n' && lastTwoChars.peekSecondLatest() == '\n' -> {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
lastTwoChars.peekLatest() == '\n' -> {
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
append("\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AnnotatedParagraphStringBuilder.ensureSingleNewline() {
|
||||||
|
when {
|
||||||
|
lastTwoChars.isEmpty() -> {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
length == 1 && lastTwoChars.peekLatest()?.isWhitespace() == true -> {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
lastTwoChars.peekLatest() == '\n' -> {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CharSequence.secondToLast(): Char {
|
||||||
|
if (count() < 2) {
|
||||||
|
throw NoSuchElementException("List has less than two items.")
|
||||||
|
}
|
||||||
|
return this[lastIndex - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> MutableList<T>.pushMaxTwo(item: T) {
|
||||||
|
this.add(0, item)
|
||||||
|
if (count() > 2) {
|
||||||
|
this.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> List<T>.peekLatest(): T? {
|
||||||
|
return this.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> List<T>.peekSecondLatest(): T? {
|
||||||
|
if (count() < 2) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ComposableStyleWithStartEnd(
|
||||||
|
val style: @Composable () -> SpanStyle,
|
||||||
|
val start: Int,
|
||||||
|
val end: Int = -1
|
||||||
|
)
|
|
@ -0,0 +1,101 @@
|
||||||
|
/*
|
||||||
|
* Feeder: Android RSS reader app
|
||||||
|
* https://gitlab.com/spacecowboy/Feeder
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Jonas Kalderstam
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.ash.reader.ui.component.reader
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A continent version of [BasicText] component to be able to handle click event on the text.
|
||||||
|
*
|
||||||
|
* This is a shorthand of [BasicText] with [pointerInput] to be able to handle click
|
||||||
|
* event easily.
|
||||||
|
*
|
||||||
|
* @sample androidx.compose.foundation.samples.ClickableText
|
||||||
|
*
|
||||||
|
* For other gestures, e.g. long press, dragging, follow sample code.
|
||||||
|
*
|
||||||
|
* @sample androidx.compose.foundation.samples.LongClickableText
|
||||||
|
*
|
||||||
|
* @see BasicText
|
||||||
|
* @see androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
* @see androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
*
|
||||||
|
* @param text The text to be displayed.
|
||||||
|
* @param modifier Modifier to apply to this layout node.
|
||||||
|
* @param style Style configuration for the text such as color, font, line height etc.
|
||||||
|
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
|
||||||
|
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
|
||||||
|
* [overflow] and [TextAlign] may have unexpected effects.
|
||||||
|
* @param overflow How visual overflow should be handled.
|
||||||
|
* @param maxLines An optional maximum number of lines for the text to span, wrapping if
|
||||||
|
* necessary. If the text exceeds the given number of lines, it will be truncated according to
|
||||||
|
* [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
|
||||||
|
* @param onTextLayout Callback that is executed when a new text layout is calculated.
|
||||||
|
* @param onClick Callback that is executed when users click the text. This callback is called
|
||||||
|
* with clicked character's offset.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ClickableTextWithInlineContent(
|
||||||
|
text: AnnotatedString,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: TextStyle = TextStyle.Default,
|
||||||
|
softWrap: Boolean = true,
|
||||||
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
|
inlineContent: Map<String, InlineTextContent> = emptyMap(),
|
||||||
|
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||||
|
onClick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
|
val pressIndicator = Modifier.pointerInput(onClick) {
|
||||||
|
detectTapGestures { pos ->
|
||||||
|
layoutResult.value?.let { layoutResult ->
|
||||||
|
onClick(layoutResult.getOffsetForPosition(pos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicText(
|
||||||
|
text = text,
|
||||||
|
modifier = modifier.then(pressIndicator),
|
||||||
|
style = style,
|
||||||
|
softWrap = softWrap,
|
||||||
|
overflow = overflow,
|
||||||
|
maxLines = maxLines,
|
||||||
|
onTextLayout = {
|
||||||
|
layoutResult.value = it
|
||||||
|
onTextLayout(it)
|
||||||
|
},
|
||||||
|
inlineContent = inlineContent
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* Feeder: Android RSS reader app
|
||||||
|
* https://gitlab.com/spacecowboy/Feeder
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Jonas Kalderstam
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.ash.reader.ui.component.reader
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.text.Annotation
|
||||||
|
import android.text.SpannedString
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.core.text.getSpans
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
fun resources(): Resources {
|
||||||
|
LocalConfiguration.current
|
||||||
|
return LocalContext.current.resources
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
|
||||||
|
val resources = resources()
|
||||||
|
val text = resources.getText(id) as SpannedString
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
this.append(text.toString())
|
||||||
|
|
||||||
|
for (annotation in text.getSpans<Annotation>()) {
|
||||||
|
when (annotation.key) {
|
||||||
|
"style" -> {
|
||||||
|
getSpanStyle(annotation.value)?.let { spanStyle ->
|
||||||
|
addStyle(
|
||||||
|
spanStyle,
|
||||||
|
text.getSpanStart(annotation),
|
||||||
|
text.getSpanEnd(annotation)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getSpanStyle(name: String?): SpanStyle? {
|
||||||
|
return when (name) {
|
||||||
|
"link" -> linkTextStyle().toSpanStyle()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,751 @@
|
||||||
|
/*
|
||||||
|
* Feeder: Android RSS reader app
|
||||||
|
* https://gitlab.com/spacecowboy/Feeder
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Jonas Kalderstam
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.ash.reader.ui.component.reader
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.foundation.text.selection.DisableSelection
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ExperimentalComposeApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.BaselineShift
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.annotation.ExperimentalCoilApi
|
||||||
|
import coil.size.Precision
|
||||||
|
import coil.size.Size
|
||||||
|
import coil.size.pxOrElse
|
||||||
|
import me.ash.reader.R
|
||||||
|
import me.ash.reader.ui.component.AsyncImage
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.helper.StringUtil
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import org.jsoup.nodes.Node
|
||||||
|
import org.jsoup.nodes.TextNode
|
||||||
|
import java.io.InputStream
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
fun LazyListScope.htmlFormattedText(
|
||||||
|
inputStream: InputStream,
|
||||||
|
baseUrl: String,
|
||||||
|
@DrawableRes imagePlaceholder: Int,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Jsoup.parse(inputStream, null, baseUrl)
|
||||||
|
?.body()
|
||||||
|
?.let { body ->
|
||||||
|
formatBody(
|
||||||
|
element = body,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.formatBody(
|
||||||
|
element: Element,
|
||||||
|
@DrawableRes imagePlaceholder: Int,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
baseUrl: String,
|
||||||
|
) {
|
||||||
|
val composer = TextComposer { paragraphBuilder ->
|
||||||
|
item {
|
||||||
|
val paragraph = paragraphBuilder.toAnnotatedString()
|
||||||
|
|
||||||
|
// ClickableText prevents taps from deselecting selected text
|
||||||
|
// So use regular Text if possible
|
||||||
|
if (paragraph.getStringAnnotations("URL", 0, paragraph.length)
|
||||||
|
.isNotEmpty()
|
||||||
|
) {
|
||||||
|
ClickableText(
|
||||||
|
text = paragraph,
|
||||||
|
style = bodyStyle(),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = PADDING_HORIZONTAL.dp)
|
||||||
|
.width(MAX_CONTENT_WIDTH.dp)
|
||||||
|
) { offset ->
|
||||||
|
paragraph.getStringAnnotations("URL", offset, offset)
|
||||||
|
.firstOrNull()
|
||||||
|
?.let {
|
||||||
|
onLinkClick(it.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = paragraph,
|
||||||
|
style = bodyStyle(),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = PADDING_HORIZONTAL.dp)
|
||||||
|
.width(MAX_CONTENT_WIDTH.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composer.appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = this,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
composer.terminateCurrentText()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.formatCodeBlock(
|
||||||
|
element: Element,
|
||||||
|
@DrawableRes imagePlaceholder: Int,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
baseUrl: String,
|
||||||
|
) {
|
||||||
|
val composer = TextComposer { paragraphBuilder ->
|
||||||
|
item {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Surface(
|
||||||
|
color = codeBlockBackground(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = PADDING_HORIZONTAL.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(all = 8.dp)
|
||||||
|
.horizontalScroll(
|
||||||
|
state = scrollState
|
||||||
|
)
|
||||||
|
.width(MAX_CONTENT_WIDTH.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = paragraphBuilder.toAnnotatedString(),
|
||||||
|
style = codeBlockStyle(),
|
||||||
|
softWrap = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composer.appendTextChildren(
|
||||||
|
element.childNodes(), preFormatted = true,
|
||||||
|
lazyListScope = this,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
composer.terminateCurrentText()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeApi::class, ExperimentalCoilApi::class)
|
||||||
|
private fun TextComposer.appendTextChildren(
|
||||||
|
nodes: List<Node>,
|
||||||
|
preFormatted: Boolean = false,
|
||||||
|
lazyListScope: LazyListScope,
|
||||||
|
@DrawableRes imagePlaceholder: Int,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
baseUrl: String,
|
||||||
|
) {
|
||||||
|
var node = nodes.firstOrNull()
|
||||||
|
while (node != null) {
|
||||||
|
when (node) {
|
||||||
|
is TextNode -> {
|
||||||
|
if (preFormatted) {
|
||||||
|
append(node.wholeText)
|
||||||
|
} else {
|
||||||
|
if (endsWithWhitespace) {
|
||||||
|
node.text().trimStart().let { trimmed ->
|
||||||
|
if (trimmed.isNotEmpty()) {
|
||||||
|
append(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node.text().let { text ->
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
append(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Element -> {
|
||||||
|
val element = node
|
||||||
|
when (element.tagName()) {
|
||||||
|
"p" -> {
|
||||||
|
// Readability4j inserts p-tags in divs for algorithmic purposes.
|
||||||
|
// They screw up formatting.
|
||||||
|
if (node.hasClass("readability-styled")) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
withParagraph {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"br" -> append('\n')
|
||||||
|
"h1" -> {
|
||||||
|
withParagraph {
|
||||||
|
withComposableStyle(
|
||||||
|
style = { h5Style().toSpanStyle() }
|
||||||
|
) {
|
||||||
|
append(element.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"h2" -> {
|
||||||
|
withParagraph {
|
||||||
|
withComposableStyle(
|
||||||
|
style = { h5Style().toSpanStyle() }
|
||||||
|
) {
|
||||||
|
append(element.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"h3" -> {
|
||||||
|
withParagraph {
|
||||||
|
withComposableStyle(
|
||||||
|
style = { h5Style().toSpanStyle() }
|
||||||
|
) {
|
||||||
|
append(element.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"h4" -> {
|
||||||
|
withParagraph {
|
||||||
|
withComposableStyle(
|
||||||
|
style = { h5Style().toSpanStyle() }
|
||||||
|
) {
|
||||||
|
append(element.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"h5" -> {
|
||||||
|
withParagraph {
|
||||||
|
withComposableStyle(
|
||||||
|
style = { h5Style().toSpanStyle() }
|
||||||
|
) {
|
||||||
|
append(element.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"h6" -> {
|
||||||
|
withParagraph {
|
||||||
|
withComposableStyle(
|
||||||
|
style = { h5Style().toSpanStyle() }
|
||||||
|
) {
|
||||||
|
append(element.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"strong", "b" -> {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"i", "em", "cite", "dfn" -> {
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tt" -> {
|
||||||
|
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"u" -> {
|
||||||
|
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"sup" -> {
|
||||||
|
withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"sub" -> {
|
||||||
|
withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"font" -> {
|
||||||
|
val fontFamily: FontFamily? = element.attr("face")?.asFontFamily()
|
||||||
|
withStyle(SpanStyle(fontFamily = fontFamily)) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pre" -> {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
preFormatted = true,
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"code" -> {
|
||||||
|
if (element.parent()?.tagName() == "pre") {
|
||||||
|
terminateCurrentText()
|
||||||
|
lazyListScope.formatCodeBlock(
|
||||||
|
element = element,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// inline code
|
||||||
|
withComposableStyle(
|
||||||
|
style = { codeInlineStyle() }
|
||||||
|
) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
preFormatted = preFormatted,
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"blockquote" -> {
|
||||||
|
withParagraph {
|
||||||
|
withComposableStyle(
|
||||||
|
style = { blockQuoteStyle() }
|
||||||
|
) {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"a" -> {
|
||||||
|
withComposableStyle(
|
||||||
|
style = { linkTextStyle().toSpanStyle() }
|
||||||
|
) {
|
||||||
|
withAnnotation("URL", element.attr("abs:href") ?: "") {
|
||||||
|
appendTextChildren(
|
||||||
|
element.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"img" -> {
|
||||||
|
val imageCandidates = getImageSource(baseUrl, element)
|
||||||
|
if (imageCandidates.hasImage) {
|
||||||
|
val alt = element.attr("alt") ?: ""
|
||||||
|
appendImage(onLinkClick = onLinkClick) { onClick ->
|
||||||
|
lazyListScope.item {
|
||||||
|
// val scale = remember { mutableStateOf(1f) }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
// .padding(horizontal = PADDING_HORIZONTAL.dp)
|
||||||
|
.width(MAX_CONTENT_WIDTH.dp)
|
||||||
|
) {
|
||||||
|
DisableSelection {
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RectangleShape)
|
||||||
|
.clickable(
|
||||||
|
enabled = onClick != null
|
||||||
|
) {
|
||||||
|
onClick?.invoke()
|
||||||
|
}
|
||||||
|
.fillMaxWidth()
|
||||||
|
// This makes scrolling a pain, find a way to solve that
|
||||||
|
// .pointerInput("imgzoom") {
|
||||||
|
// detectTransformGestures { centroid, pan, zoom, rotation ->
|
||||||
|
// val z = zoom * scale.value
|
||||||
|
// scale.value = when {
|
||||||
|
// z < 1f -> 1f
|
||||||
|
// z > 3f -> 3f
|
||||||
|
// else -> z
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
) {
|
||||||
|
val imageSize = maxImageSize()
|
||||||
|
AsyncImage(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
data = imageCandidates.getBestImageForMaxSize(
|
||||||
|
pixelDensity = pixelDensity(),
|
||||||
|
maxSize = imageSize,
|
||||||
|
),
|
||||||
|
contentDescription = alt,
|
||||||
|
size = imageSize,
|
||||||
|
precision = Precision.INEXACT,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alt.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp / 2))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = PADDING_HORIZONTAL.dp),
|
||||||
|
text = alt,
|
||||||
|
style = captionStyle(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ul" -> {
|
||||||
|
element.children()
|
||||||
|
.filter { it.tagName() == "li" }
|
||||||
|
.forEach { listItem ->
|
||||||
|
withParagraph {
|
||||||
|
// no break space
|
||||||
|
append("• ")
|
||||||
|
appendTextChildren(
|
||||||
|
listItem.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ol" -> {
|
||||||
|
element.children()
|
||||||
|
.filter { it.tagName() == "li" }
|
||||||
|
.forEachIndexed { i, listItem ->
|
||||||
|
withParagraph {
|
||||||
|
// no break space
|
||||||
|
append("${i + 1}. ")
|
||||||
|
appendTextChildren(
|
||||||
|
listItem.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"table" -> {
|
||||||
|
appendTable {
|
||||||
|
/*
|
||||||
|
In this order:
|
||||||
|
optionally a caption element (containing text children for instance),
|
||||||
|
followed by zero or more colgroup elements,
|
||||||
|
followed optionally by a thead element,
|
||||||
|
followed by either zero or more tbody elements
|
||||||
|
or one or more tr elements,
|
||||||
|
followed optionally by a tfoot element
|
||||||
|
*/
|
||||||
|
element.children()
|
||||||
|
.filter { it.tagName() == "caption" }
|
||||||
|
.forEach {
|
||||||
|
appendTextChildren(
|
||||||
|
it.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
ensureDoubleNewline()
|
||||||
|
terminateCurrentText()
|
||||||
|
}
|
||||||
|
|
||||||
|
element.children()
|
||||||
|
.filter { it.tagName() == "thead" || it.tagName() == "tbody" || it.tagName() == "tfoot" }
|
||||||
|
.flatMap {
|
||||||
|
it.children()
|
||||||
|
.filter { it.tagName() == "tr" }
|
||||||
|
}
|
||||||
|
.forEach { row ->
|
||||||
|
appendTextChildren(
|
||||||
|
row.childNodes(),
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
terminateCurrentText()
|
||||||
|
}
|
||||||
|
|
||||||
|
append("\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"iframe" -> {
|
||||||
|
val video: Video? = getVideo(element.attr("abs:src"))
|
||||||
|
|
||||||
|
if (video != null) {
|
||||||
|
appendImage(onLinkClick = onLinkClick) {
|
||||||
|
lazyListScope.item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = PADDING_HORIZONTAL.dp)
|
||||||
|
.width(MAX_CONTENT_WIDTH.dp)
|
||||||
|
) {
|
||||||
|
DisableSelection {
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
onLinkClick(video.link)
|
||||||
|
}
|
||||||
|
.fillMaxWidth(),
|
||||||
|
data = video.imageUrl,
|
||||||
|
size = maxImageSize(),
|
||||||
|
contentDescription = "点击播放视频",
|
||||||
|
precision = Precision.INEXACT,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp / 2))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = PADDING_HORIZONTAL.dp),
|
||||||
|
text = "点击播放视频",
|
||||||
|
style = captionStyle(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"video" -> {
|
||||||
|
// not implemented yet. remember to disable selection
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
appendTextChildren(
|
||||||
|
nodes = element.childNodes(),
|
||||||
|
preFormatted = preFormatted,
|
||||||
|
lazyListScope = lazyListScope,
|
||||||
|
imagePlaceholder = imagePlaceholder,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.nextSibling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
|
private fun String.asFontFamily(): FontFamily? = when (this.lowercase()) {
|
||||||
|
"monospace" -> FontFamily.Monospace
|
||||||
|
"serif" -> FontFamily.Serif
|
||||||
|
"sans-serif" -> FontFamily.SansSerif
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun testIt() {
|
||||||
|
val html = """
|
||||||
|
<p>In Gimp you go to <em>Image</em> in the top menu bar and select <em>Mode</em> followed by <em>Indexed</em>. Now you see a popup where you can select the number of colors for a generated optimum palette.</p> <p>You’ll have to experiment a little because it will depend on your image.</p> <p>I used this approach to shrink the size of the cover image in <a href="https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/">the_zopfli post</a> from a 37KB (JPG) to just 15KB (PNG, all PNG sizes listed include Zopfli compression btw).</p> <h2 id="straight-jpg-to-png-conversion-124kb">Straight JPG to PNG conversion: 124KB</h2> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things.png" alt="PNG version RGB colors" /></p> <p>First off, I exported the JPG file as a PNG file. This PNG file had a whopping 124KB! Clearly there was some bloat being stored.</p> <h2 id="256-colors-40kb">256 colors: 40KB</h2> <p>Reducing from RGB to only 256 colors has no visible effect to my eyes.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_256.png" alt="256 colors" /></p> <h2 id="128-colors-34kb">128 colors: 34KB</h2> <p>Still no difference.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_128.png" alt="128 colors" /></p> <h2 id="64-colors-25kb">64 colors: 25KB</h2> <p>You can start to see some artifacting in the shadow behind the text.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_64.png" alt="64 colors" /></p> <h2 id="32-colors-15kb">32 colors: 15KB</h2> <p>In my opinion this is the sweet spot. The shadow artifacting is barely noticable but the size is significantly reduced.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_32.png" alt="32 colors" /></p> <h2 id="16-colors-11kb">16 colors: 11KB</h2> <p>Clear artifacting in the text shadow and the yellow (fire?) in the background has developed an outline.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_16.png" alt="16 colors" /></p> <h2 id="8-colors-7-3kb">8 colors: 7.3KB</h2> <p>The broom has shifted in color from a clear brown to almost grey. Text shadow is just a grey blob at this point. Even clearer outline developed on the yellow background.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_8.png" alt="8 colors" /></p> <h2 id="4-colors-4-3kb">4 colors: 4.3KB</h2> <p>Interestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there’s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_4.png" alt="4 colors" /></p> <h2 id="2-colors-2-4kb">2 colors: 2.4KB</h2> <p>Well, at least the silhouette is well defined at this point I guess.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_2.png" alt="2 colors" /></p> <hr/> <p>Other posts in the <b>Migrating from Ghost to Hugo</b> series:</p> <ul class="series"> <li>2016-10-21 — Reduce the size of images even further by reducing number of colors with Gimp </li> <li>2016-08-26 — <a href="https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/">Compress all the images!</a> </li> <li>2016-07-25 — <a href="https://cowboyprogrammer.org/2016/07/migrating_from_ghost_to_hugo/">Migrating from Ghost to Hugo</a> </li> </ul>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
html.byteInputStream().use { stream ->
|
||||||
|
LazyColumn {
|
||||||
|
htmlFormattedText(
|
||||||
|
inputStream = stream,
|
||||||
|
baseUrl = "https://cowboyprogrammer.org",
|
||||||
|
imagePlaceholder = R.drawable.ic_telegram,
|
||||||
|
onLinkClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun pixelDensity() = with(LocalDensity.current) {
|
||||||
|
density
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxWithConstraintsScope.maxImageSize() = with(LocalDensity.current) {
|
||||||
|
val maxWidthPx = maxWidth.toPx().roundToInt()
|
||||||
|
|
||||||
|
Size(
|
||||||
|
width = maxWidth.toPx().roundToInt().coerceAtLeast(1),
|
||||||
|
height = maxHeight
|
||||||
|
.toPx()
|
||||||
|
.roundToInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
.coerceAtMost(10 * maxWidthPx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the url to the image in the <img> tag - could be from srcset or from src
|
||||||
|
*/
|
||||||
|
internal fun getImageSource(baseUrl: String, element: Element) = ImageCandidates(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
srcSet = element.attr("srcset") ?: "",
|
||||||
|
absSrc = element.attr("abs:src") ?: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class ImageCandidates(
|
||||||
|
val baseUrl: String,
|
||||||
|
val srcSet: String,
|
||||||
|
val absSrc: String
|
||||||
|
) {
|
||||||
|
val hasImage: Boolean = srcSet.isNotBlank() || absSrc.isNotBlank()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Might throw if hasImage returns false
|
||||||
|
*/
|
||||||
|
fun getBestImageForMaxSize(maxSize: Size, pixelDensity: Float): String {
|
||||||
|
val setCandidate = srcSet.splitToSequence(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.map { it.split(SpaceRegex).take(2).map { x -> x.trim() } }
|
||||||
|
.fold(100f to "") { acc, candidate ->
|
||||||
|
val candidateSize = if (candidate.size == 1) {
|
||||||
|
// Assume it corresponds to 1x pixel density
|
||||||
|
1.0f / pixelDensity
|
||||||
|
} else {
|
||||||
|
val descriptor = candidate.last()
|
||||||
|
when {
|
||||||
|
descriptor.endsWith("w", ignoreCase = true) -> {
|
||||||
|
descriptor.substringBefore("w").toFloat() / maxSize.width.pxOrElse { 1 }
|
||||||
|
}
|
||||||
|
descriptor.endsWith("x", ignoreCase = true) -> {
|
||||||
|
descriptor.substringBefore("x").toFloat() / pixelDensity
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
return@fold acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abs(candidateSize - 1.0f) < abs(acc.first - 1.0f)) {
|
||||||
|
candidateSize to candidate.first()
|
||||||
|
} else {
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.second
|
||||||
|
|
||||||
|
if (setCandidate.isNotBlank()) {
|
||||||
|
return StringUtil.resolve(baseUrl, setCandidate)
|
||||||
|
}
|
||||||
|
return StringUtil.resolve(baseUrl, absSrc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SpaceRegex = Regex("\\s+")
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Feeder: Android RSS reader app
|
||||||
|
* https://gitlab.com/spacecowboy/Feeder
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Jonas Kalderstam
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.ash.reader.ui.component.reader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import me.ash.reader.R
|
||||||
|
|
||||||
|
fun LazyListScope.reader(
|
||||||
|
context: Context,
|
||||||
|
link: String,
|
||||||
|
content: String,
|
||||||
|
) {
|
||||||
|
Log.i("RLog", "Reader: ")
|
||||||
|
htmlFormattedText(
|
||||||
|
inputStream = content.byteInputStream(),
|
||||||
|
baseUrl = link,
|
||||||
|
imagePlaceholder = R.drawable.ic_launcher_foreground,
|
||||||
|
onLinkClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse(it)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
122
app/src/main/java/me/ash/reader/ui/component/reader/Styles.kt
Normal file
122
app/src/main/java/me/ash/reader/ui/component/reader/Styles.kt
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
/*
|
||||||
|
* Feeder: Android RSS reader app
|
||||||
|
* https://gitlab.com/spacecowboy/Feeder
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Jonas Kalderstam
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.ash.reader.ui.component.reader
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import me.ash.reader.ui.ext.alphaLN
|
||||||
|
|
||||||
|
const val PADDING_HORIZONTAL = 24.0
|
||||||
|
const val MAX_CONTENT_WIDTH = 840.0
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun bodyForeground(): Color =
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun bodyStyle(): TextStyle =
|
||||||
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = bodyForeground()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun h1Style(): TextStyle =
|
||||||
|
MaterialTheme.typography.displayMedium.copy(
|
||||||
|
color = bodyForeground()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun h2Style(): TextStyle =
|
||||||
|
MaterialTheme.typography.displaySmall.copy(
|
||||||
|
color = bodyForeground()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun h3Style(): TextStyle =
|
||||||
|
MaterialTheme.typography.headlineLarge.copy(
|
||||||
|
color = bodyForeground()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun h4Style(): TextStyle =
|
||||||
|
MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
color = bodyForeground()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun h5Style(): TextStyle =
|
||||||
|
MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
color = bodyForeground()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun h6Style(): TextStyle =
|
||||||
|
MaterialTheme.typography.titleLarge.copy(
|
||||||
|
color = bodyForeground()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun captionStyle(): TextStyle =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = bodyForeground().copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun linkTextStyle(): TextStyle =
|
||||||
|
TextStyle(
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun codeBlockStyle(): TextStyle =
|
||||||
|
MaterialTheme.typography.titleSmall.merge(
|
||||||
|
SpanStyle(
|
||||||
|
color = bodyForeground(),
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun codeBlockBackground(): Color =
|
||||||
|
MaterialTheme.colorScheme.secondary.copy(alpha = (0.dp).alphaLN(weight = 3.2f))
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun blockQuoteStyle(): SpanStyle =
|
||||||
|
MaterialTheme.typography.titleSmall.toSpanStyle().merge(
|
||||||
|
SpanStyle(
|
||||||
|
fontWeight = FontWeight.Light
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun codeInlineStyle(): SpanStyle =
|
||||||
|
MaterialTheme.typography.titleSmall.toSpanStyle().copy(
|
||||||
|
color = bodyForeground(),
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
|
@ -0,0 +1,182 @@
|
||||||
|
/*
|
||||||
|
* Feeder: Android RSS reader app
|
||||||
|
* https://gitlab.com/spacecowboy/Feeder
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Jonas Kalderstam
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.ash.reader.ui.component.reader
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
|
||||||
|
class TextComposer(
|
||||||
|
val paragraphEmitter: (AnnotatedParagraphStringBuilder) -> Unit
|
||||||
|
) {
|
||||||
|
val spanStack: MutableList<Span> = mutableListOf()
|
||||||
|
|
||||||
|
// The identity of this will change - do not reference it in blocks
|
||||||
|
private var builder: AnnotatedParagraphStringBuilder = AnnotatedParagraphStringBuilder()
|
||||||
|
|
||||||
|
fun terminateCurrentText() {
|
||||||
|
if (builder.isEmpty()) {
|
||||||
|
// Nothing to emit, and nothing to reset
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphEmitter(builder)
|
||||||
|
|
||||||
|
builder = AnnotatedParagraphStringBuilder()
|
||||||
|
|
||||||
|
for (span in spanStack) {
|
||||||
|
when (span) {
|
||||||
|
is SpanWithStyle -> builder.pushStyle(span.spanStyle)
|
||||||
|
is SpanWithAnnotation -> builder.pushStringAnnotation(
|
||||||
|
tag = span.tag,
|
||||||
|
annotation = span.annotation
|
||||||
|
)
|
||||||
|
is SpanWithComposableStyle -> builder.pushComposableStyle(span.spanStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val endsWithWhitespace: Boolean
|
||||||
|
get() = builder.endsWithWhitespace
|
||||||
|
|
||||||
|
fun ensureDoubleNewline() =
|
||||||
|
builder.ensureDoubleNewline()
|
||||||
|
|
||||||
|
fun append(text: String) =
|
||||||
|
builder.append(text)
|
||||||
|
|
||||||
|
fun append(char: Char) =
|
||||||
|
builder.append(char)
|
||||||
|
|
||||||
|
fun <R> appendTable(block: () -> R): R {
|
||||||
|
builder.ensureDoubleNewline()
|
||||||
|
terminateCurrentText()
|
||||||
|
return block()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <R> appendImage(
|
||||||
|
link: String? = null,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
block: (
|
||||||
|
onClick: (() -> Unit)?
|
||||||
|
) -> R
|
||||||
|
): R {
|
||||||
|
val url = link ?: findClosestLink()
|
||||||
|
builder.ensureDoubleNewline()
|
||||||
|
terminateCurrentText()
|
||||||
|
val onClick: (() -> Unit)? = if (url?.isNotBlank() == true) {
|
||||||
|
{
|
||||||
|
onLinkClick(url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return block(onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pop(index: Int) =
|
||||||
|
builder.pop(index)
|
||||||
|
|
||||||
|
fun pushStyle(style: SpanStyle): Int =
|
||||||
|
builder.pushStyle(style)
|
||||||
|
|
||||||
|
fun pushStringAnnotation(tag: String, annotation: String): Int =
|
||||||
|
builder.pushStringAnnotation(tag = tag, annotation = annotation)
|
||||||
|
|
||||||
|
fun pushComposableStyle(style: @Composable () -> SpanStyle): Int =
|
||||||
|
builder.pushComposableStyle(style)
|
||||||
|
|
||||||
|
fun popComposableStyle(index: Int) =
|
||||||
|
builder.popComposableStyle(index)
|
||||||
|
|
||||||
|
private fun findClosestLink(): String? {
|
||||||
|
for (span in spanStack.reversed()) {
|
||||||
|
if (span is SpanWithAnnotation && span.tag == "URL") {
|
||||||
|
return span.annotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <R : Any> TextComposer.withParagraph(
|
||||||
|
crossinline block: TextComposer.() -> R
|
||||||
|
): R {
|
||||||
|
ensureDoubleNewline()
|
||||||
|
return block(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <R : Any> TextComposer.withStyle(
|
||||||
|
style: SpanStyle,
|
||||||
|
crossinline block: TextComposer.() -> R
|
||||||
|
): R {
|
||||||
|
spanStack.add(SpanWithStyle(style))
|
||||||
|
val index = pushStyle(style)
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
pop(index)
|
||||||
|
spanStack.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <R : Any> TextComposer.withComposableStyle(
|
||||||
|
noinline style: @Composable () -> SpanStyle,
|
||||||
|
crossinline block: TextComposer.() -> R
|
||||||
|
): R {
|
||||||
|
spanStack.add(SpanWithComposableStyle(style))
|
||||||
|
val index = pushComposableStyle(style)
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
popComposableStyle(index)
|
||||||
|
spanStack.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <R : Any> TextComposer.withAnnotation(
|
||||||
|
tag: String,
|
||||||
|
annotation: String,
|
||||||
|
crossinline block: TextComposer.() -> R
|
||||||
|
): R {
|
||||||
|
spanStack.add(SpanWithAnnotation(tag = tag, annotation = annotation))
|
||||||
|
val index = pushStringAnnotation(tag = tag, annotation = annotation)
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
pop(index)
|
||||||
|
spanStack.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Span
|
||||||
|
|
||||||
|
data class SpanWithStyle(
|
||||||
|
val spanStyle: SpanStyle
|
||||||
|
) : Span()
|
||||||
|
|
||||||
|
data class SpanWithAnnotation(
|
||||||
|
val tag: String,
|
||||||
|
val annotation: String
|
||||||
|
) : Span()
|
||||||
|
|
||||||
|
data class SpanWithComposableStyle(
|
||||||
|
val spanStyle: @Composable () -> SpanStyle
|
||||||
|
) : Span()
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Feeder: Android RSS reader app
|
||||||
|
* https://gitlab.com/spacecowboy/Feeder
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Jonas Kalderstam
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.ash.reader.ui.component.reader
|
||||||
|
|
||||||
|
// Example strings
|
||||||
|
// www.youtube.com/embed/cjxnVO9RpaQ
|
||||||
|
// www.youtube.com/embed/cjxnVO9RpaQ?feature=oembed
|
||||||
|
// www.youtube.com/embed/cjxnVO9RpaQ/theoretical_crap
|
||||||
|
// www.youtube.com/embed/cjxnVO9RpaQ/crap?feature=oembed
|
||||||
|
internal val YoutubeIdPattern = "youtube.com/embed/([^?/]*)".toRegex()
|
||||||
|
|
||||||
|
fun getVideo(src: String?): Video? {
|
||||||
|
return src?.let {
|
||||||
|
YoutubeIdPattern.find(src)?.let { match ->
|
||||||
|
val videoId = match.groupValues[1]
|
||||||
|
Video(
|
||||||
|
src = src,
|
||||||
|
imageUrl = "http://img.youtube.com/vi/$videoId/hqdefault.jpg",
|
||||||
|
link = "https://www.youtube.com/watch?v=$videoId"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Video(
|
||||||
|
val src: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
// Youtube needs a different link than embed links
|
||||||
|
val link: String
|
||||||
|
) {
|
||||||
|
val width: Int
|
||||||
|
get() = 480
|
||||||
|
|
||||||
|
val height: Int
|
||||||
|
get() = 360
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ package me.ash.reader.ui.ext
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import android.view.ViewConfiguration
|
import android.view.ViewConfiguration
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ScrollState
|
import androidx.compose.foundation.ScrollState
|
||||||
|
@ -88,7 +89,7 @@ fun Modifier.drawHorizontalScrollbar(
|
||||||
fun Modifier.drawVerticalScrollbar(
|
fun Modifier.drawVerticalScrollbar(
|
||||||
state: LazyListState,
|
state: LazyListState,
|
||||||
reverseScrolling: Boolean = false
|
reverseScrolling: Boolean = false
|
||||||
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling)
|
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling).animateContentSize()
|
||||||
|
|
||||||
private fun Modifier.drawScrollbar(
|
private fun Modifier.drawScrollbar(
|
||||||
state: ScrollState,
|
state: ScrollState,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.FiberManualRecord
|
import androidx.compose.material.icons.filled.FiberManualRecord
|
||||||
import androidx.compose.material.icons.outlined.Article
|
import androidx.compose.material.icons.outlined.Article
|
||||||
import androidx.compose.material.icons.outlined.FiberManualRecord
|
import androidx.compose.material.icons.outlined.FiberManualRecord
|
||||||
import androidx.compose.material.icons.outlined.TextFormat
|
import androidx.compose.material.icons.outlined.Headphones
|
||||||
import androidx.compose.material.icons.rounded.Article
|
import androidx.compose.material.icons.rounded.Article
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
import androidx.compose.material.icons.rounded.Star
|
import androidx.compose.material.icons.rounded.Star
|
||||||
|
@ -108,9 +108,9 @@ fun ReadBar(
|
||||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||||
}
|
}
|
||||||
CanBeDisabledIconButton(
|
CanBeDisabledIconButton(
|
||||||
modifier = Modifier.size(40.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
disabled = true,
|
disabled = true,
|
||||||
imageVector = Icons.Outlined.TextFormat,
|
imageVector = Icons.Outlined.Headphones,
|
||||||
contentDescription = "Add Tag",
|
contentDescription = "Add Tag",
|
||||||
tint = MaterialTheme.colorScheme.outline,
|
tint = MaterialTheme.colorScheme.outline,
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
package me.ash.reader.ui.page.home.read
|
package me.ash.reader.ui.page.home.read
|
||||||
|
|
||||||
import android.util.Log
|
import android.content.Intent
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.foundation.ScrollState
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.text.selection.DisableSelection
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Headphones
|
import androidx.compose.material.icons.outlined.Share
|
||||||
import androidx.compose.material.icons.outlined.MoreVert
|
|
||||||
import androidx.compose.material.icons.rounded.Close
|
import androidx.compose.material.icons.rounded.Close
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
@ -23,7 +23,7 @@ import androidx.navigation.NavHostController
|
||||||
import me.ash.reader.R
|
import me.ash.reader.R
|
||||||
import me.ash.reader.data.entity.ArticleWithFeed
|
import me.ash.reader.data.entity.ArticleWithFeed
|
||||||
import me.ash.reader.ui.component.FeedbackIconButton
|
import me.ash.reader.ui.component.FeedbackIconButton
|
||||||
import me.ash.reader.ui.component.WebView
|
import me.ash.reader.ui.component.reader.reader
|
||||||
import me.ash.reader.ui.ext.collectAsStateValue
|
import me.ash.reader.ui.ext.collectAsStateValue
|
||||||
import me.ash.reader.ui.ext.drawVerticalScrollbar
|
import me.ash.reader.ui.ext.drawVerticalScrollbar
|
||||||
|
|
||||||
|
@ -34,7 +34,8 @@ fun ReadPage(
|
||||||
readViewModel: ReadViewModel = hiltViewModel(),
|
readViewModel: ReadViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val viewState = readViewModel.viewState.collectAsStateValue()
|
val viewState = readViewModel.viewState.collectAsStateValue()
|
||||||
var isScrollDown by remember { mutableStateOf(false) }
|
val isScrollDown = viewState.listState.isScrollDown()
|
||||||
|
// val isScrollDown by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
navController.currentBackStackEntryFlow.collect {
|
navController.currentBackStackEntryFlow.collect {
|
||||||
|
@ -44,24 +45,7 @@ fun ReadPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewState.scrollState.isScrollInProgress) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
Log.i("RLog", "scroll: start")
|
|
||||||
}
|
|
||||||
|
|
||||||
val preScrollOffset by remember { mutableStateOf(viewState.scrollState.value) }
|
|
||||||
val currentOffset = viewState.scrollState.value
|
|
||||||
isScrollDown = currentOffset > preScrollOffset
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
Log.i("RLog", "scroll: end")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(viewState.articleWithFeed?.article?.id) {
|
LaunchedEffect(viewState.articleWithFeed?.article?.id) {
|
||||||
isScrollDown = false
|
|
||||||
viewState.articleWithFeed?.let {
|
viewState.articleWithFeed?.let {
|
||||||
if (it.article.isUnread) {
|
if (it.article.isUnread) {
|
||||||
readViewModel.dispatch(ReadViewAction.MarkUnread(false))
|
readViewModel.dispatch(ReadViewAction.MarkUnread(false))
|
||||||
|
@ -82,7 +66,8 @@ fun ReadPage(
|
||||||
) {
|
) {
|
||||||
TopBar(
|
TopBar(
|
||||||
isShow = viewState.articleWithFeed == null || !isScrollDown,
|
isShow = viewState.articleWithFeed == null || !isScrollDown,
|
||||||
isShowActions = viewState.articleWithFeed != null,
|
title = viewState.articleWithFeed?.article?.title,
|
||||||
|
link = viewState.articleWithFeed?.article?.link,
|
||||||
onClose = {
|
onClose = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
|
@ -91,8 +76,8 @@ fun ReadPage(
|
||||||
Content(
|
Content(
|
||||||
content = viewState.content ?: "",
|
content = viewState.content ?: "",
|
||||||
articleWithFeed = viewState.articleWithFeed,
|
articleWithFeed = viewState.articleWithFeed,
|
||||||
viewState = viewState,
|
isLoading = viewState.isLoading,
|
||||||
scrollState = viewState.scrollState,
|
listState = viewState.listState,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
@ -121,12 +106,39 @@ fun ReadPage(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LazyListState.isScrollDown(): Boolean {
|
||||||
|
var isScrollDown by remember { mutableStateOf(false) }
|
||||||
|
var preItemIndex by remember { mutableStateOf(0) }
|
||||||
|
var preScrollStartOffset by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(this) {
|
||||||
|
snapshotFlow { isScrollInProgress }.collect {
|
||||||
|
if (isScrollInProgress) {
|
||||||
|
isScrollDown = when {
|
||||||
|
firstVisibleItemIndex > preItemIndex -> true
|
||||||
|
firstVisibleItemScrollOffset < preItemIndex -> false
|
||||||
|
else -> firstVisibleItemScrollOffset > preScrollStartOffset
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
preItemIndex = firstVisibleItemIndex
|
||||||
|
preScrollStartOffset = firstVisibleItemScrollOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isScrollDown
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TopBar(
|
private fun TopBar(
|
||||||
isShow: Boolean,
|
isShow: Boolean,
|
||||||
isShowActions: Boolean = false,
|
title: String? = "",
|
||||||
|
link: String? = "",
|
||||||
onClose: () -> Unit = {},
|
onClose: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isShow,
|
visible = isShow,
|
||||||
enter = fadeIn() + expandVertically(),
|
enter = fadeIn() + expandVertically(),
|
||||||
|
@ -148,23 +160,19 @@ private fun TopBar(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (isShowActions) {
|
|
||||||
FeedbackIconButton(
|
FeedbackIconButton(
|
||||||
modifier = Modifier
|
modifier = Modifier.size(20.dp),
|
||||||
.size(22.dp)
|
imageVector = Icons.Outlined.Share,
|
||||||
.alpha(0.5f),
|
|
||||||
imageVector = Icons.Outlined.Headphones,
|
|
||||||
contentDescription = stringResource(R.string.mark_all_as_read),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
FeedbackIconButton(
|
|
||||||
modifier = Modifier.alpha(0.5f),
|
|
||||||
imageVector = Icons.Outlined.MoreVert,
|
|
||||||
contentDescription = stringResource(R.string.search),
|
contentDescription = stringResource(R.string.search),
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
) {
|
||||||
}
|
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TEXT,
|
||||||
|
title?.takeIf { it.isNotBlank() }?.let { it + "\n" } + link
|
||||||
|
)
|
||||||
|
type = "text/plain"
|
||||||
|
}, "Share"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -175,37 +183,37 @@ private fun TopBar(
|
||||||
private fun Content(
|
private fun Content(
|
||||||
content: String,
|
content: String,
|
||||||
articleWithFeed: ArticleWithFeed?,
|
articleWithFeed: ArticleWithFeed?,
|
||||||
viewState: ReadViewState,
|
listState: LazyListState,
|
||||||
scrollState: ScrollState = rememberScrollState(),
|
isLoading: Boolean,
|
||||||
) {
|
) {
|
||||||
Column(
|
if (articleWithFeed == null) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
SelectionContainer {
|
||||||
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.drawVerticalScrollbar(scrollState)
|
.drawVerticalScrollbar(listState),
|
||||||
.verticalScroll(scrollState),
|
state = listState,
|
||||||
) {
|
) {
|
||||||
if (articleWithFeed == null) {
|
item {
|
||||||
Spacer(modifier = Modifier.height(64.dp))
|
Spacer(modifier = Modifier.height(64.dp))
|
||||||
// LottieAnimation(
|
Spacer(modifier = Modifier.height(22.dp))
|
||||||
// modifier = Modifier
|
|
||||||
// .alpha(0.7f)
|
|
||||||
// .padding(80.dp),
|
|
||||||
// url = "https://assets8.lottiefiles.com/packages/lf20_jm7mv1ib.json",
|
|
||||||
// )
|
|
||||||
} else {
|
|
||||||
Column {
|
|
||||||
Spacer(modifier = Modifier.height(64.dp))
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
) {
|
) {
|
||||||
|
DisableSelection {
|
||||||
Header(articleWithFeed)
|
Header(articleWithFeed)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
Spacer(modifier = Modifier.height(22.dp))
|
Spacer(modifier = Modifier.height(22.dp))
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = viewState.isLoading,
|
visible = isLoading,
|
||||||
enter = fadeIn() + expandVertically(),
|
enter = fadeIn() + expandVertically(),
|
||||||
exit = fadeOut() + shrinkVertically(),
|
exit = fadeOut() + shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
|
@ -224,12 +232,15 @@ private fun Content(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!viewState.isLoading) {
|
}
|
||||||
WebView(
|
if (!isLoading) {
|
||||||
|
reader(
|
||||||
|
context = context,
|
||||||
|
link = articleWithFeed.article.link,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(50.dp))
|
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
Spacer(modifier = Modifier.height(64.dp))
|
Spacer(modifier = Modifier.height(64.dp))
|
||||||
Spacer(modifier = Modifier.height(64.dp))
|
Spacer(modifier = Modifier.height(64.dp))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package me.ash.reader.ui.page.home.read
|
package me.ash.reader.ui.page.home.read
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.ScrollState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
@ -146,7 +146,8 @@ data class ReadViewState(
|
||||||
val articleWithFeed: ArticleWithFeed? = null,
|
val articleWithFeed: ArticleWithFeed? = null,
|
||||||
val content: String? = null,
|
val content: String? = null,
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val scrollState: ScrollState = ScrollState(0),
|
// val scrollState: ScrollState = ScrollState(0),
|
||||||
|
val listState: LazyListState = LazyListState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class ReadViewAction {
|
sealed class ReadViewAction {
|
||||||
|
|
9
app/src/main/res/drawable/ic_broken_image_black_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_broken_image_black_24dp.xml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM19,19L5,19v-4.58l0.99,0.99 4,-4 4,4 4,-3.99L19,12.43L19,19zM19,9.59l-1.01,-1.01 -4,4.01 -4,-4 -4,4 -0.99,-1L5,5h14v4.59z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M8,2c-1.1,0 -2,0.9 -2,2v3.17c0,0.53 0.21,1.04 0.59,1.42L10,12l-3.42,3.42c-0.37,0.38 -0.58,0.89 -0.58,1.42L6,20c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2v-3.16c0,-0.53 -0.21,-1.04 -0.58,-1.41L14,12l3.41,-3.4c0.38,-0.38 0.59,-0.89 0.59,-1.42L18,4c0,-1.1 -0.9,-2 -2,-2L8,2zM16,16.5L16,19c0,0.55 -0.45,1 -1,1L9,20c-0.55,0 -1,-0.45 -1,-1v-2.5l4,-4 4,4zM12,11.5l-4,-4L8,5c0,-0.55 0.45,-1 1,-1h6c0.55,0 1,0.45 1,1v2.5l-4,4z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
</vector>
|
Loading…
Reference in New Issue
Block a user