Abandon markdown for rich text

Thank you Syer10 for recommending this library
- Already built component for elegant rich text editing
- Adds a dependency, could possibly remove another (NewUpdateScreen.kt's
markdown viewer)
This commit is contained in:
imkunet 2024-09-20 04:31:06 -04:00
parent 7f7177f597
commit 6dbc9efa17
No known key found for this signature in database
GPG Key ID: 32E0ECFB90A68C42
11 changed files with 206 additions and 219 deletions

View File

@ -233,6 +233,7 @@ dependencies {
}
implementation(libs.insetter)
implementation(libs.bundles.richtext)
implementation(libs.richeditor.compose)
implementation(libs.aboutLibraries.compose)
implementation(libs.bundles.voyager)
implementation(libs.compose.materialmotion)

View File

@ -1,52 +1,28 @@
package eu.kanade.presentation.manga
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.string.RichTextStringStyle
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarTitle
import eu.kanade.presentation.manga.components.MangaNotesTextArea
import eu.kanade.tachiyomi.ui.manga.notes.MangaNotesScreenState
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.EmptyScreenAction
@Composable
fun MangaNotesScreen(
state: MangaNotesScreenState.Success,
navigateUp: () -> Unit,
beginEditing: () -> Unit,
endEditing: () -> Unit,
onSave: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@ -60,98 +36,22 @@ fun MangaNotesScreen(
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
AnimatedVisibility(
true,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.imePadding(),
) {
ExtendedFloatingActionButton(
text = {
Text(
text = stringResource(
if (state.editing) MR.strings.action_apply else MR.strings.action_edit,
),
)
},
icon = {
Icon(
imageVector = if (state.editing) Icons.Filled.Check else Icons.Filled.Edit,
contentDescription = null,
)
},
onClick = { if (state.editing) endEditing() else beginEditing() },
expanded = true,
)
}
},
modifier = modifier
.imePadding(),
) { paddingValues ->
AnimatedVisibility(
state.editing,
enter = fadeIn(),
exit = fadeOut(),
) {
MangaNotesTextArea(
state = state,
onSave = onSave,
modifier = Modifier
.padding(
top = paddingValues.calculateTopPadding() + MaterialTheme.padding.small,
bottom = MaterialTheme.padding.small,
)
.padding(horizontal = MaterialTheme.padding.small)
.windowInsetsPadding(
WindowInsets.navigationBars
.only(WindowInsetsSides.Bottom),
),
)
}
AnimatedVisibility(
!state.editing && state.notes.isNullOrBlank(),
enter = fadeIn(),
exit = fadeOut(),
) {
EmptyScreen(
stringRes = MR.strings.information_no_notes,
modifier = Modifier.padding(paddingValues),
actions = persistentListOf(
EmptyScreenAction(
MR.strings.action_add_notes,
Icons.Filled.EditNote,
beginEditing,
),
MangaNotesTextArea(
state = state,
onSave = onSave,
modifier = Modifier
.padding(
top = paddingValues.calculateTopPadding() + MaterialTheme.padding.small,
bottom = MaterialTheme.padding.small,
)
.padding(horizontal = MaterialTheme.padding.small)
.windowInsetsPadding(
WindowInsets.navigationBars
.only(WindowInsetsSides.Bottom),
),
)
}
AnimatedVisibility(
!state.editing && !state.notes.isNullOrBlank(),
enter = fadeIn(),
exit = fadeOut(),
) {
SelectionContainer {
RichText(
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.padding(
horizontal = MaterialTheme.padding.medium,
vertical = paddingValues.calculateTopPadding() + MaterialTheme.padding.medium,
),
style = RichTextStyle(
stringStyle = RichTextStringStyle(
linkStyle = SpanStyle(color = MaterialTheme.colorScheme.primary),
),
),
) {
Markdown(content = state.notes.orEmpty())
}
}
}
)
}
}

View File

@ -113,7 +113,6 @@ fun MangaScreen(
onEditFetchIntervalClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
onNotesClicked: () -> Unit,
onNotesEditClicked: () -> Unit,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
@ -163,7 +162,6 @@ fun MangaScreen(
onEditIntervalClicked = onEditFetchIntervalClicked,
onMigrateClicked = onMigrateClicked,
onNotesClicked = onNotesClicked,
onNotesEditClicked = onNotesEditClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
@ -200,7 +198,6 @@ fun MangaScreen(
onEditIntervalClicked = onEditFetchIntervalClicked,
onMigrateClicked = onMigrateClicked,
onNotesClicked = onNotesClicked,
onNotesEditClicked = onNotesEditClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
@ -247,7 +244,6 @@ private fun MangaScreenSmallImpl(
onEditIntervalClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
onNotesClicked: () -> Unit,
onNotesEditClicked: () -> Unit,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
@ -428,7 +424,7 @@ private fun MangaScreenSmallImpl(
noteContent = state.manga.notes,
onTagSearch = onTagSearch,
onCopyTagToClipboard = onCopyTagToClipboard,
onClickNotes = onNotesEditClicked,
onClickNotes = onNotesClicked,
)
}
@ -498,7 +494,6 @@ fun MangaScreenLargeImpl(
onEditIntervalClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
onNotesClicked: () -> Unit,
onNotesEditClicked: () -> Unit,
// For bottom action menu
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
@ -659,7 +654,7 @@ fun MangaScreenLargeImpl(
noteContent = state.manga.notes,
onTagSearch = onTagSearch,
onCopyTagToClipboard = onCopyTagToClipboard,
onClickNotes = onNotesEditClicked,
onClickNotes = onNotesClicked,
)
}
},

View File

@ -0,0 +1,28 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
import com.mohamedrejeb.richeditor.model.rememberRichTextState
import com.mohamedrejeb.richeditor.ui.material3.RichText
@OptIn(ExperimentalRichTextApi::class)
@Composable
fun MangaNotesDisplay(
content: String,
modifier: Modifier,
) {
val richTextState = rememberRichTextState().setHtml(html = content)
richTextState.config.linkColor = MaterialTheme.colorScheme.primary
richTextState.config.listIndent = 15
SelectionContainer {
RichText(
modifier = modifier,
style = MaterialTheme.typography.bodyMedium,
state = richTextState,
)
}
}

View File

@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.HorizontalDivider
@ -25,13 +24,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.string.RichTextStringStyle
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Button
import tachiyomi.presentation.core.components.material.ButtonDefaults
@ -50,19 +44,10 @@ fun MangaNotesSection(
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!content.isNullOrBlank()) {
SelectionContainer {
RichText(
modifier = Modifier
.fillMaxWidth(),
style = RichTextStyle(
stringStyle = RichTextStringStyle(
linkStyle = SpanStyle(color = MaterialTheme.colorScheme.primary),
),
),
) {
Markdown(content = content)
}
}
MangaNotesDisplay(
content = content,
modifier = modifier.fillMaxWidth(),
)
AnimatedVisibility(
visible = expanded,

View File

@ -1,26 +1,52 @@
package eu.kanade.presentation.manga.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.OutlinedTextField
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
import androidx.compose.material.icons.outlined.FormatBold
import androidx.compose.material.icons.outlined.FormatItalic
import androidx.compose.material.icons.outlined.FormatListNumbered
import androidx.compose.material.icons.outlined.FormatSize
import androidx.compose.material.icons.outlined.FormatUnderlined
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.mohamedrejeb.richeditor.model.rememberRichTextState
import com.mohamedrejeb.richeditor.ui.material3.RichTextEditor
import eu.kanade.tachiyomi.ui.manga.notes.MangaNotesScreenState
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
private const val MAX_LENGTH = 10_000
@ -30,49 +56,135 @@ fun MangaNotesTextArea(
onSave: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var text by remember {
mutableStateOf(TextFieldValue(state.notes.orEmpty(), TextRange(Int.MAX_VALUE)))
}
val richTextState = rememberRichTextState()
richTextState.config.linkColor = MaterialTheme.colorScheme.primary
richTextState.config.listIndent = 15
val focusRequester = remember { FocusRequester() }
Box(
val largeFontSize = MaterialTheme.typography.headlineMedium.fontSize
Column(
modifier = modifier
.fillMaxSize(),
) {
OutlinedTextField(
value = text,
onValueChange = { if (it.text.length <= MAX_LENGTH) text = it },
modifier = Modifier
.fillMaxSize()
.focusRequester(focusRequester),
supportingText = {
val displayWarning = text.text.length > MAX_LENGTH / 10 * 9
if (!displayWarning) {
Text(
text = "0",
modifier = Modifier.alpha(0f),
)
}
AnimatedVisibility(
displayWarning,
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = "${text.text.length} / $MAX_LENGTH",
)
}
RichTextEditor(
state = richTextState,
textStyle = MaterialTheme.typography.bodyMedium,
maxLength = MAX_LENGTH,
placeholder = {
Text(text = stringResource(MR.strings.notes_placeholder))
},
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.focusRequester(focusRequester),
)
AnimatedVisibility(
visible = WindowInsets.isImeVisible,
) {
LazyRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.padding(top = 4.dp),
) {
item {
SlackDemoPanelButton(
onClick = { richTextState.toggleSpanStyle(SpanStyle(fontWeight = FontWeight.Bold)) },
isSelected = richTextState.currentSpanStyle.fontWeight == FontWeight.Bold,
icon = Icons.Outlined.FormatBold,
)
}
item {
SlackDemoPanelButton(
onClick = { richTextState.toggleSpanStyle(SpanStyle(fontStyle = FontStyle.Italic)) },
isSelected = richTextState.currentSpanStyle.fontStyle == FontStyle.Italic,
icon = Icons.Outlined.FormatItalic,
)
}
item {
SlackDemoPanelButton(
onClick = {
richTextState.toggleSpanStyle(SpanStyle(textDecoration = TextDecoration.Underline))
},
isSelected =
richTextState.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
icon = Icons.Outlined.FormatUnderlined,
)
}
item {
VerticalDivider(
modifier = Modifier
.height(24.dp),
)
}
item {
SlackDemoPanelButton(
onClick = { richTextState.toggleUnorderedList() },
isSelected = richTextState.isUnorderedList,
icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
)
}
item {
SlackDemoPanelButton(
onClick = { richTextState.toggleOrderedList() },
isSelected = richTextState.isOrderedList,
icon = Icons.Outlined.FormatListNumbered,
)
}
item {
VerticalDivider(
modifier = Modifier
.height(24.dp),
)
}
item {
SlackDemoPanelButton(
onClick = { richTextState.toggleSpanStyle(SpanStyle(fontSize = largeFontSize)) },
isSelected = richTextState.currentSpanStyle.fontSize == largeFontSize,
icon = Icons.Outlined.FormatSize,
)
}
}
}
}
LaunchedEffect(focusRequester) {
state.notes?.let { richTextState.setHtml(it) }
focusRequester.requestFocus()
}
DisposableEffect(Unit) {
onDispose {
onSave(text.text)
onSave(richTextState.toHtml())
}
}
}
@Composable
fun SlackDemoPanelButton(
onClick: () -> Unit,
icon: ImageVector,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.clickable(
onClick = onClick,
enabled = true,
role = Role.Button,
),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = icon.name,
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
modifier = Modifier
.background(color = if (isSelected) MaterialTheme.colorScheme.onBackground else Color.Transparent)
.padding(6.dp),
)
}
}

View File

@ -166,7 +166,6 @@ class MangaScreen(
navigator.push(MigrateSearchScreen(successState.manga.id))
}.takeIf { successState.manga.favorite },
onNotesClicked = { navigator.push(MangaNotesScreen(manga = successState.manga)) },
onNotesEditClicked = { navigator.push(MangaNotesScreen(manga = successState.manga, editing = true)) },
onMultiBookmarkClicked = screenModel::bookmarkChapters,
onMultiMarkAsReadClicked = screenModel::markChaptersRead,
onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead,

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.ui.manga.notes
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -14,7 +13,6 @@ import tachiyomi.presentation.core.screens.LoadingScreen
class MangaNotesScreen(
private val manga: Manga,
private val editing: Boolean = false,
) : Screen() {
@Composable
override fun Content() {
@ -23,7 +21,6 @@ class MangaNotesScreen(
val screenModel = rememberScreenModel {
MangaNotesScreenModel(
manga = manga,
editing = editing,
)
}
val state by screenModel.state.collectAsState()
@ -35,22 +32,9 @@ class MangaNotesScreen(
val successState = state as MangaNotesScreenState.Success
BackHandler(
onBack = {
if (!successState.editing) {
navigator.pop()
return@BackHandler
}
screenModel.endEditing()
},
)
MangaNotesScreen(
state = successState,
navigateUp = navigator::pop,
beginEditing = { screenModel.beginEditing() },
endEditing = { screenModel.endEditing() },
onSave = { screenModel.saveText(it) },
)
}

View File

@ -12,7 +12,6 @@ import uy.kohesive.injekt.api.get
class MangaNotesScreenModel(
val manga: Manga,
editing: Boolean,
private val setMangaNotes: SetMangaNotes = Injekt.get(),
) : StateScreenModel<MangaNotesScreenState>(MangaNotesScreenState.Loading) {
@ -24,29 +23,10 @@ class MangaNotesScreenModel(
MangaNotesScreenState.Success(
manga = manga,
notes = manga.notes,
editing = editing,
)
}
}
fun beginEditing() {
mutableState.update {
when (it) {
MangaNotesScreenState.Loading -> it
is MangaNotesScreenState.Success -> it.copy(editing = true)
}
}
}
fun endEditing() {
mutableState.update {
when (it) {
MangaNotesScreenState.Loading -> it
is MangaNotesScreenState.Success -> it.copy(editing = false)
}
}
}
fun saveText(content: String) {
// don't save what isn't modified
if (content == successState?.notes) return
@ -75,7 +55,5 @@ sealed interface MangaNotesScreenState {
data class Success(
val manga: Manga,
val notes: String?,
val editing: Boolean = false,
) : MangaNotesScreenState
}

View File

@ -56,6 +56,8 @@ natural-comparator = "com.github.gpanther:java-nat-sort:natural-comparator-1.1"
richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" }
richtext-m3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "richtext" }
richeditor-compose = "com.mohamedrejeb.richeditor:richeditor-compose:1.0.0-rc06"
material = "com.google.android.material:material:1.12.0"
flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533"
photoview = "com.github.chrisbanes:PhotoView:2.3.0"

View File

@ -146,9 +146,9 @@
<string name="action_move_to_top_all_for_series">Move series to top</string>
<string name="action_move_to_bottom">Move to bottom</string>
<string name="action_move_to_bottom_all_for_series">Move series to bottom</string>
<string name="action_notes">Notes</string>
<string name="action_add_notes">Add Notes</string>
<string name="action_edit_notes">Edit Notes</string>
<string name="action_notes">Note</string>
<string name="action_add_notes">Add Note</string>
<string name="action_edit_notes">Edit Note</string>
<string name="action_install">Install</string>
<string name="action_share">Share</string>
<string name="action_save">Save</string>
@ -915,7 +915,7 @@
<string name="information_cloudflare_bypass_failure">Failed to bypass Cloudflare</string>
<string name="information_cloudflare_help">Tap here for help with Cloudflare</string>
<string name="information_required_plain">*required</string>
<string name="information_no_notes">There are no notes here yet!</string>
<string name="information_no_notes">No notes</string>
<!-- Do not translate "WebView" -->
<string name="information_webview_required">WebView is required for the app to function</string>
<!-- Do not translate "WebView" -->
@ -956,4 +956,7 @@
<string name="exception_http">HTTP %d, check website in WebView</string>
<string name="exception_offline">No Internet connection</string>
<string name="exception_unknown_host">Couldn\'t reach %s</string>
<!-- Notes screen -->
<string name="notes_placeholder">My analysis of the story begins with ontological antirealism, that is to say...</string>
</resources>