Skip to content

Commit

Permalink
Fix line wrapping breaks selection position
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedRejeb committed Nov 24, 2024
1 parent 3850e35 commit 4b77db1
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -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 &&
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ internal class RichParagraph(
textIndex: Int,
offset: Int = 0,
ignoreCustomFiltering: Boolean = false,
returnFirstIfEmpty: Boolean = false,
): Pair<Int, RichSpan?> {
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
Expand All @@ -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(
Expand All @@ -60,6 +64,10 @@ internal class RichParagraph(
else
index = result.first
}

if (returnFirstIfEmpty && index == textIndex)
return index to getFirstNonEmptyChild()

return index to null
}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

}
109 changes: 109 additions & 0 deletions richeditor-compose/src/desktopTest/kotlin/model/AdjustSelectionTest.kt
Original file line number Diff line number Diff line change
@@ -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(
"""
<p>fsdfdsf</p>
<br>
<p>fsdfsdfdsf aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
<br>
<p>fsdfsdfdsf</p>
<br>
""".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()
}

}
1 change: 0 additions & 1 deletion sample/desktop/src/jvmMain/kotlin/Main.kt
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down

0 comments on commit 4b77db1

Please sign in to comment.