Improve ergonomics of editor:

- Show indicator in title for when file has unsaved changes
- Fix issues with scrolling on preview
  - Default to disable locked swiping between tabs
  - Disable gestures on navigation drawer
 - Fix text selection and typing in editor
This commit is contained in:
William Brawner 2024-05-05 19:44:36 -06:00
parent f4c7057daf
commit c86d2c2f6a
Signed by: wbrawner
GPG key ID: 8FF12381C6C90D35
2 changed files with 82 additions and 55 deletions

View file

@ -22,11 +22,19 @@ data class EditorState(
val fileName: String = "Untitled.md", val fileName: String = "Untitled.md",
val markdown: String = "", val markdown: String = "",
val path: URI? = null, val path: URI? = null,
val dirty: Boolean = false,
val toast: String? = null, val toast: String? = null,
val alert: AlertDialogModel? = null, val alert: AlertDialogModel? = null,
val saveCallback: (() -> Unit)? = null val saveCallback: (() -> Unit)? = null,
) /**
* Used to signal to the view that it should reload due to an external change, like loading
* a new file
*/
val reloadToggle: Int = 0,
private val initialMarkdown: String = "",
) {
val dirty: Boolean
get() = markdown != initialMarkdown
}
class MarkdownViewModel( class MarkdownViewModel(
private val fileHelper: FileHelper, private val fileHelper: FileHelper,
@ -45,7 +53,6 @@ class MarkdownViewModel(
fun updateMarkdown(markdown: String?) { fun updateMarkdown(markdown: String?) {
_state.value = _state.value.copy( _state.value = _state.value.copy(
markdown = markdown ?: "", markdown = markdown ?: "",
dirty = true
) )
} }
@ -81,11 +88,13 @@ class MarkdownViewModel(
val uri = URI.create(actualLoadPath) val uri = URI.create(actualLoadPath)
fileHelper.open(uri) fileHelper.open(uri)
?.let { (name, content) -> ?.let { (name, content) ->
_state.value = _state.value.copy( val currentState = _state.value
_state.value = currentState.copy(
path = uri, path = uri,
fileName = name, fileName = name,
markdown = content, markdown = content,
dirty = false, initialMarkdown = content,
reloadToggle = currentState.reloadToggle.inv(),
toast = "Successfully loaded $name" toast = "Successfully loaded $name"
) )
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
@ -120,7 +129,7 @@ class MarkdownViewModel(
_state.value = currentState.copy( _state.value = currentState.copy(
fileName = name, fileName = name,
path = actualSavePath, path = actualSavePath,
dirty = false, initialMarkdown = currentState.markdown,
toast = if (interactive) "Successfully saved $name" else null toast = if (interactive) "Successfully saved $name" else null
) )
Timber.i("Saved file $name to uri $actualSavePath") Timber.i("Saved file $name to uri $actualSavePath")

View file

@ -50,6 +50,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -100,7 +101,10 @@ fun MainScreen(
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val fileName by viewModel.collectAsState(EditorState::fileName, "") val fileName by viewModel.collectAsState(EditorState::fileName, "")
val initialMarkdown by viewModel.collectAsState(EditorState::markdown, "")
val reloadToggle by viewModel.collectAsState(EditorState::reloadToggle, 0)
val markdown by viewModel.collectAsState(EditorState::markdown, "") val markdown by viewModel.collectAsState(EditorState::markdown, "")
val dirty by viewModel.collectAsState(EditorState::dirty, false)
val alert by viewModel.collectAsState(EditorState::alert, null) val alert by viewModel.collectAsState(EditorState::alert, null)
val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null) val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
LaunchedEffect(enableAutosave) { LaunchedEffect(enableAutosave) {
@ -112,7 +116,10 @@ fun MainScreen(
} }
val toast by viewModel.collectAsState(EditorState::toast, null) val toast by viewModel.collectAsState(EditorState::toast, null)
MainScreen( MainScreen(
dirty = dirty,
fileName = fileName, fileName = fileName,
reloadToggle = reloadToggle,
initialMarkdown = initialMarkdown,
markdown = markdown, markdown = markdown,
setMarkdown = viewModel::updateMarkdown, setMarkdown = viewModel::updateMarkdown,
message = toast, message = toast,
@ -145,6 +152,9 @@ fun MainScreen(
@Composable @Composable
private fun MainScreen( private fun MainScreen(
fileName: String = "Untitled.md", fileName: String = "Untitled.md",
dirty: Boolean = false,
reloadToggle: Int = 0,
initialMarkdown: String = "",
markdown: String = "", markdown: String = "",
setMarkdown: (String) -> Unit = {}, setMarkdown: (String) -> Unit = {},
message: String? = null, message: String? = null,
@ -160,7 +170,7 @@ private fun MainScreen(
enableWideLayout: Boolean = false, enableWideLayout: Boolean = false,
enableReadability: Boolean = false enableReadability: Boolean = false
) { ) {
var lockSwiping by remember { mutableStateOf(false) } var lockSwiping by remember { mutableStateOf(true) }
val openFileLauncher = val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
loadFile(it) loadFile(it)
@ -212,7 +222,8 @@ private fun MainScreen(
Scaffold( Scaffold(
topBar = { topBar = {
val context = LocalContext.current val context = LocalContext.current
MarkdownTopAppBar(title = fileName, MarkdownTopAppBar(
title = if (dirty) "$fileName*" else fileName,
backAsUp = false, backAsUp = false,
goBack = navigateBack, goBack = navigateBack,
drawerState = drawerState, drawerState = drawerState,
@ -290,6 +301,7 @@ private fun MainScreen(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.weight(1f), .weight(1f),
reload = reloadToggle,
markdown = markdown, markdown = markdown,
setMarkdown = setMarkdown, setMarkdown = setMarkdown,
enableReadability = enableReadability enableReadability = enableReadability
@ -314,10 +326,12 @@ private fun MainScreen(
.padding(paddingValues) .padding(paddingValues)
) { ) {
TabbedMarkdownEditor( TabbedMarkdownEditor(
initialMarkdown = initialMarkdown,
markdown = markdown, markdown = markdown,
setMarkdown = setMarkdown, setMarkdown = setMarkdown,
lockSwiping = lockSwiping, lockSwiping = lockSwiping,
enableReadability = enableReadability enableReadability = enableReadability,
reloadToggle = reloadToggle
) )
} }
} }
@ -328,10 +342,12 @@ private fun MainScreen(
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
private fun TabbedMarkdownEditor( private fun TabbedMarkdownEditor(
initialMarkdown: String,
markdown: String, markdown: String,
setMarkdown: (String) -> Unit, setMarkdown: (String) -> Unit,
lockSwiping: Boolean, lockSwiping: Boolean,
enableReadability: Boolean enableReadability: Boolean,
reloadToggle: Int
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { 2 } val pagerState = rememberPagerState { 2 }
@ -357,9 +373,10 @@ private fun TabbedMarkdownEditor(
if (page == 0) { if (page == 0) {
MarkdownTextField( MarkdownTextField(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
markdown = markdown, markdown = initialMarkdown,
setMarkdown = setMarkdown, setMarkdown = setMarkdown,
enableReadability = enableReadability enableReadability = enableReadability,
reload = reloadToggle
) )
} else { } else {
MarkdownText(modifier = Modifier.fillMaxSize(), markdown) MarkdownText(modifier = Modifier.fillMaxSize(), markdown)
@ -367,7 +384,8 @@ private fun TabbedMarkdownEditor(
} }
} }
private fun String.annotateReadability(): AnnotatedString { private fun String.annotate(enableReadability: Boolean): AnnotatedString {
if (!enableReadability) return AnnotatedString(this)
val readability = Readability(this) val readability = Readability(this)
val annotated = AnnotatedString.Builder(this) val annotated = AnnotatedString.Builder(this)
for (sentence in readability.sentences()) { for (sentence in readability.sentences()) {
@ -385,43 +403,46 @@ fun MarkdownNavigationDrawer(
) { ) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
DismissibleNavigationDrawer(drawerState = drawerState, drawerContent = { DismissibleNavigationDrawer(
DismissibleDrawerSheet { gesturesEnabled = false,
Row( drawerState = drawerState,
modifier = Modifier.fillMaxWidth(), drawerContent = {
verticalAlignment = Alignment.CenterVertically DismissibleDrawerSheet {
) { Row(
Icon( modifier = Modifier.fillMaxWidth(),
modifier = Modifier.size(96.dp), verticalAlignment = Alignment.CenterVertically
painter = painterResource(R.drawable.ic_launcher_foreground), ) {
contentDescription = null, Icon(
tint = MaterialTheme.colorScheme.onSurface modifier = Modifier.size(96.dp),
) painter = painterResource(R.drawable.ic_launcher_foreground),
Text( contentDescription = null,
text = "Simple Markdown", tint = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.titleLarge )
) Text(
} text = "Simple Markdown",
Route.entries.forEach { route -> style = MaterialTheme.typography.titleLarge
if (route == Route.EDITOR) { )
return@forEach
} }
NavigationDrawerItem( Route.entries.forEach { route ->
icon = { if (route == Route.EDITOR) {
Icon(imageVector = route.icon, contentDescription = null) return@forEach
},
label = { Text(route.title) },
selected = false,
onClick = {
navigate(route)
coroutineScope.launch {
drawerState.close()
}
} }
) NavigationDrawerItem(
icon = {
Icon(imageVector = route.icon, contentDescription = null)
},
label = { Text(route.title) },
selected = false,
onClick = {
navigate(route)
coroutineScope.launch {
drawerState.close()
}
}
)
}
} }
} }) {
}) {
content(drawerState) content(drawerState)
} }
} }
@ -469,23 +490,20 @@ fun MarkdownTopAppBar(
@Composable @Composable
fun MarkdownTextField( fun MarkdownTextField(
modifier: Modifier, modifier: Modifier,
reload: Int,
markdown: String, markdown: String,
setMarkdown: (String) -> Unit, setMarkdown: (String) -> Unit,
enableReadability: Boolean = false enableReadability: Boolean = false
) { ) {
val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) } val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) }
val (composition, setComposition) = remember { mutableStateOf<TextRange?>(null) } val (composition, setComposition) = remember { mutableStateOf<TextRange?>(null) }
val textFieldValue = remember(markdown) { val (textFieldValue, setTextFieldValue) = remember(reload) {
val annotatedMarkdown = if (enableReadability) { mutableStateOf(TextFieldValue(markdown.annotate(enableReadability), selection, composition))
markdown.annotateReadability()
} else {
AnnotatedString(markdown)
}
TextFieldValue(annotatedMarkdown, selection, composition)
} }
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = { val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
setSelection(it.selection) setSelection(it.selection)
setComposition(it.composition) setComposition(it.composition)
setTextFieldValue(it.copy(annotatedString = it.text.annotate(enableReadability)))
setMarkdown(it.text) setMarkdown(it.text)
} }