From 4b77db15c03e7a6caf068dbdb6ed25180fa28c86 Mon Sep 17 00:00:00 2001 From: MohamedRejeb Date: Sun, 24 Nov 2024 13:23:16 +0100 Subject: [PATCH] Fix line wrapping breaks selection position --- .../mohamedrejeb/richeditor/model/RichSpan.kt | 2 +- .../richeditor/model/RichTextState.kt | 67 ++++++----- .../richeditor/paragraph/RichParagraph.kt | 50 ++++++-- .../model/RichTextStateTest.kt | 24 ++++ .../kotlin/model/AdjustSelectionTest.kt | 109 ++++++++++++++++++ sample/desktop/src/jvmMain/kotlin/Main.kt | 1 - 6 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 richeditor-compose/src/desktopTest/kotlin/model/AdjustSelectionTest.kt diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpan.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpan.kt index 06135f7e..81476da9 100644 --- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpan.kt +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpan.kt @@ -31,7 +31,7 @@ internal class RichSpan( * * @return The full text range of the rich span */ - private val fullTextRange: TextRange get() { + internal val fullTextRange: TextRange get() { var textRange = this.textRange var lastChild: RichSpan? = this while (true) { diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichTextState.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichTextState.kt index 0800ec05..8b0498c6 100644 --- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichTextState.kt +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichTextState.kt @@ -28,8 +28,8 @@ import com.mohamedrejeb.richeditor.utils.* import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlin.math.absoluteValue import kotlin.math.max import kotlin.reflect.KClass @@ -271,8 +271,8 @@ public class RichTextState internal constructor( require(textRange.max <= textFieldValue.text.length) { "The end index must be within the text bounds. " + - "The text length is ${textFieldValue.text.length}, " + - "but the end index is ${textRange.max}." + "The text length is ${textFieldValue.text.length}, " + + "but the end index is ${textRange.max}." } onTextFieldValueChange( @@ -1297,8 +1297,19 @@ public class RichTextState internal constructor( startTypeIndex = max(startTypeIndex, activeRichSpan.textRange.min) val startIndex = max(0, startTypeIndex - activeRichSpan.textRange.min) - val beforeText = activeRichSpan.text.substring(0, startIndex) - val afterText = activeRichSpan.text.substring(startIndex) + val beforeText = + if (activeRichSpan.text.isEmpty()) + "" + else + activeRichSpan.text.substring(0, startIndex) + + val afterText = + if (activeRichSpan.text.isEmpty()) + "" + else + activeRichSpan.text.substring(startIndex) + + val isMentionText = typedText.startsWith(RichSpanStyle.Mention.MentionTrigger) val activeRichSpanFullSpanStyle = activeRichSpan.fullSpanStyle val newSpanStyle = activeRichSpanFullSpanStyle.customMerge(toAddSpanStyle).unmerge(toRemoveSpanStyle) @@ -2759,8 +2770,9 @@ public class RichTextState internal constructor( var pressY = pressPosition.y val textLayoutResult = this.textLayoutResult ?: return var index = 0 + var lastIndex = 0 + for (i in 0 until textLayoutResult.lineCount) { - index = i val start = textLayoutResult.getLineStart(i) val top = textLayoutResult.getLineTop(i) @@ -2776,36 +2788,37 @@ public class RichTextState internal constructor( break if (top > pressY) { - index = i - 1 + index = lastIndex break } - } - if (textLayoutResult.lineCount > richParagraphList.size) { - val start = textLayoutResult.getLineStart(index) - val top = textLayoutResult.getLineTop(index) + lastIndex = index - val lineTextStartIndex = - textLayoutResult.getOffsetForPosition( - position = Offset(start.toFloat(), top.toFloat()) + richParagraphList.getOrNull(index)?.let { paragraph -> + val textRange = paragraph.getTextRange().coerceIn( + 0, textLayoutResult.layoutInput.text.text.lastIndex ) - val lineParagraph = getRichParagraphByTextIndex(lineTextStartIndex) ?: return - - val lineParagraphStart = lineParagraph.getFirstNonEmptyChild()?.textRange?.min ?: return + val pStartTop = textLayoutResult.getBoundingBox(textRange.min).top + val pEndTop = textLayoutResult.getBoundingBox(textRange.max).top - index = richParagraphList.indexOf(lineParagraph) + val pStartEndTopDiff = (pStartTop - pEndTop).absoluteValue + val pEndTopLTopDiff = (pEndTop - top).absoluteValue - val lineParagraphStartBounds = textLayoutResult.getBoundingBox(lineParagraphStart) - - if (index > 0 && lineParagraphStartBounds.top > pressY) - index-- + if (pStartEndTopDiff < 2f || pEndTopLTopDiff < 2f || pEndTop < top) { + index++ + } + } } val selectedParagraph = richParagraphList.getOrNull(index) ?: return val nextParagraph = richParagraphList.getOrNull(index + 1) val nextParagraphStart = - nextParagraph?.getFirstNonEmptyChild()?.textRange?.min?.minus(nextParagraph.type.startText.length) + if (nextParagraph == null) + null + else + (nextParagraph.getFirstNonEmptyChild() ?: nextParagraph.type.startRichSpan) + .textRange.min.minus(nextParagraph.type.startText.length) if ( selection.collapsed && @@ -2852,9 +2865,11 @@ public class RichTextState internal constructor( * @param textIndex The text index to search for. * @return The [RichParagraph] that contains the given [textIndex], or null if no such [RichParagraph] exists. */ - private fun getRichParagraphByTextIndex(textIndex: Int): RichParagraph? { - if (singleParagraphMode) return richParagraphList.firstOrNull() - if (textIndex < 0) return richParagraphList.firstOrNull() + private fun getRichParagraphByTextIndex( + textIndex: Int, + ): RichParagraph? { + if (singleParagraphMode || textIndex < 0) + return richParagraphList.firstOrNull() var index = 0 var paragraphIndex = -1 diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/paragraph/RichParagraph.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/paragraph/RichParagraph.kt index 997058d5..088e42cf 100644 --- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/paragraph/RichParagraph.kt +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/paragraph/RichParagraph.kt @@ -25,11 +25,13 @@ internal class RichParagraph( textIndex: Int, offset: Int = 0, ignoreCustomFiltering: Boolean = false, + returnFirstIfEmpty: Boolean = false, ): Pair { var index = offset // If the paragraph is not the first one, we add 1 to the index which stands for the line break - if (paragraphIndex > 0) index++ + if (paragraphIndex > 0) + index++ // Set the startRichSpan paragraph and textRange to ensure that it has the correct and latest values type.startRichSpan.paragraph = this @@ -39,15 +41,17 @@ internal class RichParagraph( index += type.startText.length // If the paragraph is empty, we add a RichSpan to avoid skipping the paragraph when searching - if (children.isEmpty()) children.add( - RichSpan( - paragraph = this, - textRange = TextRange(index), + if (children.isEmpty()) + children.add( + RichSpan( + paragraph = this, + textRange = TextRange(index), + ) ) - ) // Check if the textIndex is in the startRichSpan current paragraph - if (index > textIndex) return index to getFirstNonEmptyChild(offset = index) + if (index > textIndex) + return index to getFirstNonEmptyChild(offset = index) children.fastForEach { richSpan -> val result = richSpan.getRichSpanByTextIndex( @@ -60,6 +64,10 @@ internal class RichParagraph( else index = result.first } + + if (returnFirstIfEmpty && index == textIndex) + return index to getFirstNonEmptyChild() + return index to null } @@ -131,6 +139,20 @@ internal class RichParagraph( return this } + fun getTextRange(): TextRange { + var start = type.startRichSpan.textRange.min + var end = 0 + + if (type.startRichSpan.text.isNotEmpty()) + end += type.startRichSpan.text.length + + children.lastOrNull()?.let { richSpan -> + end = richSpan.fullTextRange.end + } + + return TextRange(start, end) + } + fun isEmpty(ignoreStartRichSpan: Boolean = true): Boolean { if (!ignoreStartRichSpan && !type.startRichSpan.isEmpty()) return false @@ -160,21 +182,29 @@ internal class RichParagraph( if (richSpan.text.isNotEmpty()) { if (offset != -1) richSpan.textRange = TextRange(offset, offset + richSpan.text.length) + return richSpan - } - else { + } else { val result = richSpan.getFirstNonEmptyChild(offset) - if (result != null) return result + + if (result != null) + return result } } + val firstChild = children.firstOrNull() + children.clear() + if (firstChild != null) { firstChild.children.clear() + if (offset != -1) firstChild.textRange = TextRange(offset, offset + firstChild.text.length) + children.add(firstChild) } + return firstChild } diff --git a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichTextStateTest.kt b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichTextStateTest.kt index bcc6b5d9..f96260ea 100644 --- a/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichTextStateTest.kt +++ b/richeditor-compose/src/commonTest/kotlin/com.mohamedrejeb.richeditor/model/RichTextStateTest.kt @@ -1237,4 +1237,28 @@ class RichTextStateTest { assertTrue(state.isUnorderedList) } + @Test + fun testAddingTwoConsecutiveLineBreaks() { + val state = RichTextState() + + state.setText("Hello") + + state.onTextFieldValueChange( + TextFieldValue( + text = "Hello\n", + selection = TextRange(6), + ) + ) + + state.onTextFieldValueChange( + TextFieldValue( + text = "Hello \n", + selection = TextRange(7), + ) + ) + + assertEquals(3, state.richParagraphList.size) + assertEquals("Hello\n\n", state.toText()) + } + } \ No newline at end of file diff --git a/richeditor-compose/src/desktopTest/kotlin/model/AdjustSelectionTest.kt b/richeditor-compose/src/desktopTest/kotlin/model/AdjustSelectionTest.kt new file mode 100644 index 00000000..a35350af --- /dev/null +++ b/richeditor-compose/src/desktopTest/kotlin/model/AdjustSelectionTest.kt @@ -0,0 +1,109 @@ +package model + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.dp +import com.mohamedrejeb.richeditor.model.rememberRichTextState +import com.mohamedrejeb.richeditor.ui.BasicRichTextEditor +import kotlinx.coroutines.delay +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class AdjustSelectionTest { + @get:Rule + val rule = createComposeRule() + + @OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class, InternalComposeUiApi::class) + @Test + fun adjustSelectionTest() = runDesktopComposeUiTest { + // Declares a mock UI to demonstrate API calls + // + // Replace with your own declarations to test the code in your project + scene.setContent { + val state = rememberRichTextState() + + var clickPosition by remember { + mutableStateOf(Offset.Zero) + } + val clickPositionState by rememberUpdatedState(clickPosition) + + LaunchedEffect(Unit) { + state.setHtml( + """ +

fsdfdsf

+
+

fsdfsdfdsf aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

+
+

fsdfsdfdsf

+
+ """.trimIndent() + ) + } + + Box( + modifier = Modifier + .width(200.dp) + ) { + BasicRichTextEditor( + state = state, + onTextLayout = { textLayoutResult -> + val top = textLayoutResult.getLineTop(6) + val bottom = textLayoutResult.getLineBottom(6) + val height = bottom - top + + clickPosition = Offset( + x = 100f, + y = top + height / 2f + ) + }, + modifier = Modifier + .testTag("editor") + .fillMaxWidth() + ) + } + + LaunchedEffect(Unit) { + delay(1000) + + scene.sendPointerEvent( + eventType = PointerEventType.Press, + position = clickPositionState, + ) + scene.sendPointerEvent( + eventType = PointerEventType.Release, + position = clickPositionState, + ) + + delay(1000) + + scene.sendPointerEvent( + eventType = PointerEventType.Press, + position = clickPositionState, + ) + scene.sendPointerEvent( + eventType = PointerEventType.Release, + position = clickPositionState, + ) + + delay(1000) + + assertEquals(TextRange(73), state.selection) + } + } + waitForIdle() + } + +} \ No newline at end of file diff --git a/sample/desktop/src/jvmMain/kotlin/Main.kt b/sample/desktop/src/jvmMain/kotlin/Main.kt index c0f8e866..91ed2caa 100644 --- a/sample/desktop/src/jvmMain/kotlin/Main.kt +++ b/sample/desktop/src/jvmMain/kotlin/Main.kt @@ -1,7 +1,6 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.mohamedrejeb.richeditor.sample.common.App -import com.mohamedrejeb.richeditor.sample.common.htmleditor.HtmlEditorContent fun main() = application {