Skip to content

Commit f8696d6

Browse files
committed
test: add regression tests for copy line-break preservation (#626)
Issue #626 reported that copying styled text from the editor and pasting into a plain-text target (e.g. the Android notes app) collapsed everything onto a single line. The issue was filed on 2025-10-21, before rich clipboard support landed in 3770676 (2026-04-11, PR #642). Before #642, the library relied on Compose's default copy, which reads from the transformed annotated string — and since the annotated string replaces '\n' with ' ' for rendering, the plain text payload lost paragraph breaks. #642 overrode setClipEntry on all platforms to build the payload from toText(copySelection) and toHtml(copySelection), both of which preserve '\n' and block tags. These tests lock in that behavior across seven scenarios: - copy across <p> paragraphs (plain and styled) - copy across <br> paragraphs (isFromLineBreak = true) - copy with mixed inline styles across <br> boundaries - mid-range copy that crosses a paragraph boundary - copy across an ordered list - toHtml(copySelection) preserves block-level tags Marking #626 as already fixed by #642.
1 parent ccc54bb commit f8696d6

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.mohamedrejeb.richeditor.model
2+
3+
import androidx.compose.ui.text.TextRange
4+
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertTrue
8+
9+
/**
10+
* Regression tests for issue #626 — copied plain text loses line
11+
* breaks when the selection spans multiple paragraphs or line-break
12+
* ([isFromLineBreak]) paragraphs produced from HTML `<br>`.
13+
*
14+
* The clipboard plain-text payload is produced by
15+
* [RichTextState.toText] with the copy selection. When the text
16+
* lacks `\n` between paragraphs, pasting into a plain-text target
17+
* (e.g. a notes app) collapses everything onto a single line.
18+
*/
19+
@OptIn(ExperimentalRichTextApi::class)
20+
class RichTextStateCopyLineBreaksTest {
21+
22+
@Test
23+
fun testCopyAcrossParagraphsPreservesLineBreaks() {
24+
val state = RichTextState()
25+
state.setHtml("<p>First paragraph</p><p>Second paragraph</p><p>Third paragraph</p>")
26+
val text = state.textFieldValue.text
27+
28+
val copied = state.toText(TextRange(0, text.length))
29+
30+
assertEquals(2, copied.count { it == '\n' }, "Three paragraphs should be separated by two newlines: <$copied>")
31+
assertTrue(copied.contains("First"))
32+
assertTrue(copied.contains("Second"))
33+
assertTrue(copied.contains("Third"))
34+
}
35+
36+
@Test
37+
fun testCopyAcrossStyledParagraphsPreservesLineBreaks() {
38+
val state = RichTextState()
39+
state.setHtml(
40+
"<p><b>First paragraph</b></p>" +
41+
"<p><i>Second paragraph</i></p>" +
42+
"<p><u>Third paragraph</u></p>"
43+
)
44+
val text = state.textFieldValue.text
45+
46+
val copied = state.toText(TextRange(0, text.length))
47+
48+
assertEquals(
49+
"First paragraph\nSecond paragraph\nThird paragraph",
50+
copied,
51+
)
52+
}
53+
54+
@Test
55+
fun testCopyAcrossBrLineBreaksPreservesNewlines() {
56+
// <br> inside a <p> produces sibling paragraphs with isFromLineBreak = true
57+
val state = RichTextState()
58+
state.setHtml("<p>Line one<br>Line two<br>Line three</p>")
59+
val text = state.textFieldValue.text
60+
61+
val copied = state.toText(TextRange(0, text.length))
62+
63+
assertEquals(
64+
"Line one\nLine two\nLine three",
65+
copied,
66+
)
67+
}
68+
69+
@Test
70+
fun testCopyMidRangeAcrossParagraphsPreservesLineBreak() {
71+
val state = RichTextState()
72+
state.setHtml("<p>First paragraph</p><p>Second paragraph</p>")
73+
val text = state.textFieldValue.text
74+
75+
// Select "paragraph\nSecond"
76+
val start = text.indexOf("paragraph")
77+
val end = text.indexOf("Second") + "Second".length
78+
val copied = state.toText(TextRange(start, end))
79+
80+
assertEquals("paragraph\nSecond", copied)
81+
}
82+
83+
@Test
84+
fun testCopyAcrossOrderedListPreservesLineBreaks() {
85+
val state = RichTextState()
86+
state.setHtml("<ol><li>First</li><li>Second</li><li>Third</li></ol>")
87+
val text = state.textFieldValue.text
88+
89+
val copied = state.toText(TextRange(0, text.length))
90+
91+
assertEquals(2, copied.count { it == '\n' }, "List items should be separated by newlines: <$copied>")
92+
}
93+
94+
@Test
95+
fun testCopyAcrossBrParagraphsWithStylesPreservesLineBreaks() {
96+
val state = RichTextState()
97+
state.setHtml(
98+
"<p><b>Bold line</b><br><i>Italic line</i><br><u>Underline line</u></p>"
99+
)
100+
val text = state.textFieldValue.text
101+
102+
val copied = state.toText(TextRange(0, text.length))
103+
104+
assertEquals(
105+
"Bold line\nItalic line\nUnderline line",
106+
copied,
107+
)
108+
}
109+
110+
@Test
111+
fun testCopyAcrossParagraphsProducesHtmlWithBlockBreaks() {
112+
// Clipboard consumers that read the HTML payload (e.g. Android's
113+
// Html.fromHtml in receiving apps) rely on block-level tags to insert
114+
// line breaks. Ensure the HTML preserves paragraph structure.
115+
val state = RichTextState()
116+
state.setHtml("<p>First paragraph</p><p>Second paragraph</p>")
117+
val text = state.textFieldValue.text
118+
119+
val html = state.toHtml(TextRange(0, text.length))
120+
121+
assertTrue(
122+
html.contains("<p") || html.contains("<br"),
123+
"Copied HTML should contain block or line-break tags to preserve paragraphs: <$html>",
124+
)
125+
}
126+
}

0 commit comments

Comments
 (0)