Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ import com.plusmobileapps.chefmate.ui.components.PlusTooltipPlacement
import com.plusmobileapps.chefmate.ui.components.WindowSizeClass
import com.plusmobileapps.chefmate.ui.text.parseListLine
import com.plusmobileapps.chefmate.ui.text.toDisplayAnnotatedString
import com.plusmobileapps.chefmate.ui.text.withoutLineBreakTags
import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
Expand Down Expand Up @@ -1107,4 +1108,9 @@ private fun CookModeBottomBar(
}
}

private fun splitLines(text: String): List<String> = text.split("\n").filter { it.isNotBlank() }
/**
* Splits stored recipe text into displayable lines, dropping blanks and the stray `<br>` tags
* recipes edited by an older build may still carry (see [withoutLineBreakTags]).
*/
private fun splitLines(text: String): List<String> =
text.withoutLineBreakTags().split("\n").filter { it.isNotBlank() }
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.plusmobileapps.chefmate.recipe.data.IngredientScaler
import com.plusmobileapps.chefmate.recipe.data.IngredientSection
import com.plusmobileapps.chefmate.recipe.data.Recipe
import com.plusmobileapps.chefmate.recipe.data.RecipeRepository
import com.plusmobileapps.chefmate.ui.text.withoutLineBreakTags
import com.russhwolf.settings.Settings
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
Expand Down Expand Up @@ -145,7 +146,7 @@ class AddRecipeToGroceryListViewModel(
return@launch
}
val lines =
recipe.ingredients.split("\n").filter {
recipe.ingredients.withoutLineBreakTags().split("\n").filter {
it.isNotBlank() && !IngredientSection.isHeader(it)
}
_state.update { it.copy(isLoading = false, recipe = recipe, ingredientLines = lines) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ import com.plusmobileapps.chefmate.ui.text.ListLine
import com.plusmobileapps.chefmate.ui.text.parseListLine
import com.plusmobileapps.chefmate.ui.text.toDisplayAnnotatedString
import com.plusmobileapps.chefmate.ui.text.toInlineMarkdownAnnotatedString
import com.plusmobileapps.chefmate.ui.text.withoutLineBreakTags
import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme
import com.plusmobileapps.chefmate.util.rememberShareLauncher
import kotlin.time.ExperimentalTime
Expand Down Expand Up @@ -1282,7 +1283,12 @@ private fun DetailRow(
}
}

private fun splitLines(text: String): List<String> = text.split("\n").filter { it.isNotBlank() }
/**
* Splits stored recipe text into displayable lines, dropping blanks and the stray `<br>` tags
* recipes edited by an older build may still carry (see [withoutLineBreakTags]).
*/
private fun splitLines(text: String): List<String> =
text.withoutLineBreakTags().split("\n").filter { it.isNotBlank() }

private fun formatRecipeAsText(recipe: Recipe): String = buildString {
appendLine(recipe.title)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import com.plusmobileapps.chefmate.ui.text.toDisplayAnnotatedString
import com.plusmobileapps.chefmate.ui.text.toggleBulletList
import com.plusmobileapps.chefmate.ui.text.toggleInlineMarker
import com.plusmobileapps.chefmate.ui.text.toggleNumberedList
import com.plusmobileapps.chefmate.ui.text.withoutLineBreakTags
import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
Expand Down Expand Up @@ -116,7 +117,8 @@ class PlusMarkdownEditorController {
* [showListButtons] adds bulleted/numbered list buttons to the toolbar — meaningful for the
* one-item-per-line ingredient and direction fields, omitted for free-form fields like description.
*
* A drag handle in the bottom-end corner resizes the editor between [minHeight] and [maxHeight].
* A drag handle below the field, aligned to the end, resizes the editor between [minHeight] and
* [maxHeight].
*/
@Composable
fun PlusMarkdownEditor(
Expand Down Expand Up @@ -167,17 +169,34 @@ fun PlusMarkdownEditor(
val latestOnValueChange by rememberUpdatedState(onValueChange)
// Seed the initial content synchronously so it's present on the first frame (no empty flash).
remember(richTextState) { richTextState.setMarkdown(value) }
LaunchedEffect(value) {
// The rich editor's markdown as the caller last saw it. Tracking it lets both halves of the
// sync below compare without re-serializing the whole document on every keystroke.
var richTextMarkdown by remember { mutableStateOf(value) }

// Both effects run only while rich text is the active editor. In Markdown mode the raw field
// owns the value, and letting the hidden rich state write back would round-trip every keystroke
// through the library's parser — which turns a pair of trailing blank lines into a literal
// `<br>` that the user then cannot delete, because deleting it re-creates it.
LaunchedEffect(richTextMode, value) {
if (!richTextMode) return@LaunchedEffect
// Load external markdown into the rich editor; the equality guard avoids resetting the
// caret
// while the user is typing (we just emitted this exact value).
if (richTextState.toMarkdown() != value) richTextState.setMarkdown(value)
// caret while the user is typing (we just emitted this exact value).
if (value != richTextMarkdown) {
richTextMarkdown = value
richTextState.setMarkdown(value)
}
}
LaunchedEffect(richTextState) {
LaunchedEffect(richTextMode, richTextState) {
if (!richTextMode) return@LaunchedEffect
snapshotFlow { richTextState.annotatedString }
.collect {
val markdown = richTextState.toMarkdown()
if (markdown != latestValue) latestOnValueChange(markdown)
// Blank paragraphs serialize to `<br>`, which is meaningless in the plain
// one-item-per-line text the caller stores — normalize it back to a blank line.
val markdown = richTextState.toMarkdown().withoutLineBreakTags()
richTextMarkdown = markdown
// Compare against the normalized caller value so a recipe saved with a stray
// `<br>` by an older build isn't reported as an edit the moment it's opened.
if (markdown != latestValue.withoutLineBreakTags()) latestOnValueChange(markdown)
}
}

Expand Down Expand Up @@ -279,9 +298,14 @@ fun PlusMarkdownEditor(
}
}
}
}

// The handle sits below the field rather than overlaying its bottom-end corner: on touch
// platforms an overlay swallows the drags that move the caret and selection handles, which
// land in exactly that corner on the last line.
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
ResizeHandle(
label = label,
modifier = Modifier.align(Alignment.BottomEnd),
onResizeBy = { deltaDp ->
heightDp = (heightDp + deltaDp).coerceIn(minHeight.value, maxHeight.value)
},
Expand Down Expand Up @@ -416,7 +440,7 @@ private fun MarkdownPreview(text: String, modifier: Modifier = Modifier) {
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingExtraSmall),
) {
text.split("\n").forEach { line ->
text.withoutLineBreakTags().split("\n").forEach { line ->
Text(
text = parseListLine(line).toDisplayAnnotatedString(),
style = MaterialTheme.typography.bodyLarge,
Expand All @@ -428,7 +452,11 @@ private fun MarkdownPreview(text: String, modifier: Modifier = Modifier) {
}

@Composable
private fun ResizeHandle(label: String, modifier: Modifier, onResizeBy: (Float) -> Unit) {
private fun ResizeHandle(
label: String,
onResizeBy: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(Res.string.markdown_editor_resize_handle_a11y, label),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,26 @@ private fun TextFieldValue.toggleLinePrefix(
selection = TextRange(lineStart, lineStart + rewritten.length),
)
}

/** The HTML tag the rich-text editor serializes a run of consecutive blank paragraphs to. */
private const val LINE_BREAK_TAG = "<br>"

/**
* Rewrites the standalone `<br>` lines the rich-text editor emits for consecutive blank paragraphs
* back into the plain blank lines they stand for.
*
* Recipe text is stored as plain, one-item-per-line markdown and rendered with the inline parser,
* which knows nothing about HTML — so a `<br>` that reaches storage shows up as a literal line of
* text on the detail and cook screens, and as a stray item when ingredients are added to a grocery
* list. The blank line it stands for survives the round trip through the rich editor unchanged, so
* dropping the tag is lossless. Lines without the tag are returned untouched.
*/
fun String.withoutLineBreakTags(): String =
if (!contains(LINE_BREAK_TAG)) {
this
} else {
split("\n").joinToString("\n") { line ->
if (!line.contains(LINE_BREAK_TAG)) line
else line.replace(LINE_BREAK_TAG, "").let { if (it.isBlank()) "" else it }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.plusmobileapps.chefmate.ui.text

import com.mohamedrejeb.richeditor.model.RichTextState
import io.kotest.matchers.shouldBe
import kotlin.test.Test

class LineBreakTagsTest {

@Test
fun textWithoutTagsIsUnchanged() {
"1 cup flour\n2 eggs\n\n- pinch of salt".withoutLineBreakTags() shouldBe
"1 cup flour\n2 eggs\n\n- pinch of salt"
}

@Test
fun standaloneTagBecomesBlankLine() {
"Step one\n\n<br>".withoutLineBreakTags() shouldBe "Step one\n\n"
}

@Test
fun consecutiveTagsEachBecomeBlankLines() {
"Step one\n\n<br>\n<br>".withoutLineBreakTags() shouldBe "Step one\n\n\n"
}

@Test
fun tagIsStrippedFromALineThatKeepsItsText() {
"Step one<br>".withoutLineBreakTags() shouldBe "Step one"
}

/**
* The bug this normalization exists for: pressing enter twice at the end of a field left a
* literal `<br>` in the stored text that reappeared as fast as it was deleted.
*/
@Test
fun trailingBlankParagraphsDoNotSerializeToATag() {
val state = RichTextState()
state.setMarkdown("Step one\n\n")

state.toMarkdown() shouldBe "Step one\n\n<br>"
state.toMarkdown().withoutLineBreakTags() shouldBe "Step one\n\n"
}

/**
* The editor feeds the normalized markdown straight back into the rich editor when the value
* changes externally, so normalizing must be a fixed point — otherwise the two halves of the
* sync fight each other on every keystroke.
*/
@Test
fun normalizedMarkdownRoundTripsThroughTheRichEditor() {
listOf(
"",
"Step one",
"1 cup flour\n2 eggs",
"Step one\n\n",
"- a\n- b\n- c",
"1. a\n2. b",
"**bold** step\nplain step",
)
.forEach { markdown ->
val state = RichTextState()
state.setMarkdown(markdown)

state.toMarkdown().withoutLineBreakTags() shouldBe markdown
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading