From f777914dfebd162f3e03e89bf1d750798c463e96 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 11 Jun 2026 22:45:18 -0700 Subject: [PATCH 1/2] Preserve list formatting when copy/pasting list items Line-anchored rich spans (ordered/bullet lists, blockquote, code fence) live in RichSpanManager, not in the AnnotatedString the clipboard carries. A copy/paste round-trip therefore kept the paragraph indent (which is part of the AnnotatedString) but dropped the rich span, so pasted list lines were indented yet no longer rendered as numbered/bulleted items. Remember the rich spans within the copied range in an in-editor buffer and re-apply them on paste of the same text, mirroring the existing undo/redo PreservedRichSpan machinery. The text-match guard keeps stale spans off content pasted from another source. Fixes #19 Co-Authored-By: Claude Opus 4.8 --- .../input/TextEditorKeyCommandHandler.kt | 8 +- .../texteditor/state/TextEditHistory.kt | 5 + .../texteditor/state/TextEditorState.kt | 54 +++++++++++ .../kotlin/spans/RichSpanClipboardTest.kt | 92 +++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt index 23f3bcd..0742f14 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt @@ -166,8 +166,9 @@ internal class TextEditorKeyCommandHandler { } private fun handleCopy(state: TextEditorState, clipboard: Clipboard, scope: CoroutineScope) { - state.selector.selection?.let { + state.selector.selection?.let { selection -> val selectedText = state.selector.getSelectedText() + state.copyRichSpans(selection) scope.launch { ClipboardHelper.setText(clipboard, selectedText) } @@ -175,8 +176,9 @@ internal class TextEditorKeyCommandHandler { } private fun handleCut(state: TextEditorState, clipboard: Clipboard, scope: CoroutineScope) { - state.selector.selection?.let { + state.selector.selection?.let { selection -> val selectedText = state.selector.getSelectedText() + state.copyRichSpans(selection) state.selector.deleteSelection() scope.launch { ClipboardHelper.setText(clipboard, selectedText) @@ -188,11 +190,13 @@ internal class TextEditorKeyCommandHandler { scope.launch { ClipboardHelper.getText(clipboard)?.let { text -> val curSelection = state.selector.selection + val insertPosition = curSelection?.start ?: state.cursorPosition if (curSelection != null) { state.replace(curSelection, text) } else { state.insertStringAtCursor(text) } + state.pasteRichSpans(insertPosition, text) state.selector.clearSelection() } } diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditHistory.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditHistory.kt index 8c29470..9276069 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditHistory.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditHistory.kt @@ -45,6 +45,11 @@ data class PreservedRichSpan( val style: RichSpanStyle ) +data class CopiedRichSpans( + val text: String, + val spans: List +) + data class OperationMetadata( val deletedText: AnnotatedString? = null, val deletedSpans: List = emptyList(), diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt index 01e678d..e589729 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt @@ -151,6 +151,13 @@ class TextEditorState( internal val editManager = TextEditManager(this) val richSpanManager = RichSpanManager(this) + // In-editor rich-span clipboard. The system clipboard only carries the + // AnnotatedString (text + character-level spans), so line-anchored rich spans + // like ordered/bullet lists would be lost on a copy→paste round-trip. We + // remember them here keyed by the copied text and re-apply on paste of the + // same text. Null until the first copy/cut. + private var copiedRichSpans: CopiedRichSpans? = null + /** * Platform-specific extensions for TextEditorState. * On Android: Contains IME-related functionality (cursor anchor monitoring, etc.) @@ -860,6 +867,53 @@ class TextEditorState( ) } + /** + * Remembers the rich spans (ordered/bullet list, blockquote, etc.) within + * [range] so a subsequent [pasteRichSpans] can restore them. Call from the + * copy/cut handlers alongside writing the text to the system clipboard. + */ + fun copyRichSpans(range: TextEditorRange) { + val preserved = richSpanManager.getSpansInRange(range).map { span -> + PreservedRichSpan( + relativeStart = getRelativePosition(span.range.start, range.start), + relativeEnd = getRelativePosition(span.range.end, range.start), + style = span.style + ) + } + copiedRichSpans = if (preserved.isEmpty()) { + null + } else { + CopiedRichSpans(text = getStringInRange(range), spans = preserved) + } + } + + /** + * Re-applies the rich spans captured by [copyRichSpans] at [insertPosition]. + * No-op unless [pastedText] matches the text that was copied — this keeps + * stale spans off content pasted from another source. + */ + fun pasteRichSpans(insertPosition: CharLineOffset, pastedText: AnnotatedString) { + val copied = copiedRichSpans ?: return + if (copied.text != pastedText.text) return + copied.spans.forEach { preserved -> + val startPos = CharLineOffset( + line = insertPosition.line + preserved.relativeStart.lineDiff, + char = if (preserved.relativeStart.lineDiff == 0) + insertPosition.char + preserved.relativeStart.char + else + preserved.relativeStart.char + ) + val endPos = CharLineOffset( + line = insertPosition.line + preserved.relativeEnd.lineDiff, + char = if (preserved.relativeEnd.lineDiff == 0) + insertPosition.char + preserved.relativeEnd.char + else + preserved.relativeEnd.char + ) + addRichSpan(startPos, endPos, preserved.style) + } + } + private fun getRelativePosition( pos: CharLineOffset, basePos: CharLineOffset diff --git a/ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt b/ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt new file mode 100644 index 0000000..88ef9ef --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt @@ -0,0 +1,92 @@ +package spans + +import androidx.compose.ui.text.AnnotatedString +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.TextEditorRange +import com.darkrockstudios.texteditor.richstyle.OrderedListSpanStyle +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RichSpanClipboardTest { + + private fun TestScope.createState(text: String): TextEditorState = + TextEditorState( + scope = this, + measurer = mockk(relaxed = true), + initialText = AnnotatedString(text), + ) + + private fun TextEditorState.orderedLines(): List = + richSpanManager.getAllRichSpans() + .filter { it.style === OrderedListSpanStyle } + .map { it.range.start.line } + .sorted() + + @Test + fun `copy and paste of ordered-list lines preserves the ordered-list spans`() = runTest { + val state = createState("one\ntwo\nblank\n") + // Mark the first two lines as ordered-list items. + state.addRichSpan( + CharLineOffset(0, 0), + CharLineOffset(0, state.textLines[0].length), + OrderedListSpanStyle, + ) + state.addRichSpan( + CharLineOffset(1, 0), + CharLineOffset(1, state.textLines[1].length), + OrderedListSpanStyle, + ) + assertEquals(listOf(0, 1), state.orderedLines()) + + // Copy the two list lines (line 0 start through end of line 1). + val copyRange = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(1, state.textLines[1].length), + ) + val copiedText = state.getTextInRange(copyRange) + state.copyRichSpans(copyRange) + + // Paste at the trailing empty line. + val pasteLine = state.textLines.lastIndex + state.cursor.updatePosition(CharLineOffset(pasteLine, 0)) + state.insertStringAtCursor(copiedText) + state.pasteRichSpans(CharLineOffset(pasteLine, 0), copiedText) + + // Original two list lines plus the two freshly pasted ones. + assertEquals(4, state.orderedLines().size) + assertTrue( + state.orderedLines().containsAll(listOf(pasteLine, pasteLine + 1)), + "pasted lines should carry the ordered-list span", + ) + } + + @Test + fun `paste does not apply stale spans when pasted text differs from what was copied`() = runTest { + val state = createState("item\nblank") + state.addRichSpan( + CharLineOffset(0, 0), + CharLineOffset(0, state.textLines[0].length), + OrderedListSpanStyle, + ) + + val copyRange = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, state.textLines[0].length), + ) + state.copyRichSpans(copyRange) + + // Paste different text (e.g. content that came from an external app) — the + // remembered spans must not be re-applied. + val external = AnnotatedString("external") + state.cursor.updatePosition(CharLineOffset(1, 0)) + state.insertStringAtCursor(external) + state.pasteRichSpans(CharLineOffset(1, 0), external) + + assertEquals(listOf(0), state.orderedLines()) + } +} From baf9b6117d48d5e357478a23fde20056025fd242 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 11 Jun 2026 23:12:43 -0700 Subject: [PATCH 2/2] Fix rich-span clipboard review findings Clamp copied rich spans to the copy range so a span partially overlapping the selection no longer yields negative relative offsets and a corrupt span on paste; skip spans that collapse to empty after clamping. Invalidate the in-editor rich-span buffer on any document edit that is not the cut/paste's own edit, so a buffer captured before an intervening edit (or identical text copied from another source after the document changed) cannot apply stale spans. The text-equality match remains a secondary guard. Cut and paste arm a one-edit exemption so they keep the buffer they depend on. Co-Authored-By: Claude Opus 4.8 --- .../input/TextEditorKeyCommandHandler.kt | 2 + .../texteditor/state/TextEditManager.kt | 1 + .../texteditor/state/TextEditorState.kt | 51 ++++++++++-- .../kotlin/spans/RichSpanClipboardTest.kt | 81 ++++++++++++++++++- 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt index 0742f14..6fc816c 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt @@ -179,6 +179,7 @@ internal class TextEditorKeyCommandHandler { state.selector.selection?.let { selection -> val selectedText = state.selector.getSelectedText() state.copyRichSpans(selection) + state.preserveCopiedRichSpansThroughNextEdit() state.selector.deleteSelection() scope.launch { ClipboardHelper.setText(clipboard, selectedText) @@ -191,6 +192,7 @@ internal class TextEditorKeyCommandHandler { ClipboardHelper.getText(clipboard)?.let { text -> val curSelection = state.selector.selection val insertPosition = curSelection?.start ?: state.cursorPosition + state.preserveCopiedRichSpansThroughNextEdit() if (curSelection != null) { state.replace(curSelection, text) } else { diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt index ab7b0f0..05ebd79 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt @@ -37,6 +37,7 @@ class TextEditManager(private val state: TextEditorState) { } state.cursor.updatePosition(operation.cursorAfter) + state.invalidateCopiedRichSpans() state.richSpanManager.updateSpans(operation, metadata) if (addToHistory) { history.recordEdit(operation, metadata ?: OperationMetadata()) diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt index e589729..fabc7a2 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt @@ -158,6 +158,10 @@ class TextEditorState( // same text. Null until the first copy/cut. private var copiedRichSpans: CopiedRichSpans? = null + // Exempts the next single edit from clearing [copiedRichSpans], so a cut's + // delete or a paste's insert/replace doesn't wipe the buffer it depends on. + private var richSpanBufferSurvivesNextEdit = false + /** * Platform-specific extensions for TextEditorState. * On Android: Contains IME-related functionality (cursor anchor monitoring, etc.) @@ -873,10 +877,18 @@ class TextEditorState( * copy/cut handlers alongside writing the text to the system clipboard. */ fun copyRichSpans(range: TextEditorRange) { - val preserved = richSpanManager.getSpansInRange(range).map { span -> + // getSpansInRange returns spans that merely OVERLAP the copy range. A span + // starting before range.start (partial selection of a list item, or a + // multi-line span only partly covered) would yield a negative relative + // offset and a corrupt span on paste, so clamp each span to the copy range + // and drop any that collapse to empty/inverted. + val preserved = richSpanManager.getSpansInRange(range).mapNotNull { span -> + val clampedStart = maxOf(span.range.start, range.start) + val clampedEnd = minOf(span.range.end, range.end) + if (clampedStart >= clampedEnd) return@mapNotNull null PreservedRichSpan( - relativeStart = getRelativePosition(span.range.start, range.start), - relativeEnd = getRelativePosition(span.range.end, range.start), + relativeStart = getRelativePosition(clampedStart, range.start), + relativeEnd = getRelativePosition(clampedEnd, range.start), style = span.style ) } @@ -887,10 +899,39 @@ class TextEditorState( } } + /** + * Exempts the next single edit from invalidating the rich-span buffer. Call + * immediately before an edit that must not clear the buffer: the delete in a + * cut, or the insert/replace in a paste. + */ + internal fun preserveCopiedRichSpansThroughNextEdit() { + richSpanBufferSurvivesNextEdit = copiedRichSpans != null + } + + /** + * Drops the remembered rich spans. Any document mutation that is not the + * paste's own edit invalidates the buffer, so a buffer captured before an + * intervening edit — or text that merely happens to match content copied from + * another source after the document changed — cannot apply stale spans. + */ + internal fun invalidateCopiedRichSpans() { + if (richSpanBufferSurvivesNextEdit) { + richSpanBufferSurvivesNextEdit = false + return + } + copiedRichSpans = null + } + /** * Re-applies the rich spans captured by [copyRichSpans] at [insertPosition]. - * No-op unless [pastedText] matches the text that was copied — this keeps - * stale spans off content pasted from another source. + * No-op unless [pastedText] matches the text that was copied. The text match + * is a secondary guard; the primary protection against stale spans is + * [invalidateCopiedRichSpans], which clears the buffer on any intervening edit. + * + * Residual limitation: if the user copies identical text from another app with + * no editor edit in between, the text match still succeeds and the in-editor + * spans apply. Fully closing this needs platform-clipboard ownership tracking, + * which is out of scope here. */ fun pasteRichSpans(insertPosition: CharLineOffset, pastedText: AnnotatedString) { val copied = copiedRichSpans ?: return diff --git a/ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt b/ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt index 88ef9ef..fd28965 100644 --- a/ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt +++ b/ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt @@ -51,9 +51,11 @@ class RichSpanClipboardTest { val copiedText = state.getTextInRange(copyRange) state.copyRichSpans(copyRange) - // Paste at the trailing empty line. + // Paste at the trailing empty line, mirroring the paste handler: the buffer + // is armed to survive the paste's own insert, then re-applied afterwards. val pasteLine = state.textLines.lastIndex state.cursor.updatePosition(CharLineOffset(pasteLine, 0)) + state.preserveCopiedRichSpansThroughNextEdit() state.insertStringAtCursor(copiedText) state.pasteRichSpans(CharLineOffset(pasteLine, 0), copiedText) @@ -65,6 +67,83 @@ class RichSpanClipboardTest { ) } + @Test + fun `copying a sub-range of a list item produces well-formed pasted spans`() = runTest { + val state = createState("hello world\nblank") + // Span the whole first line as an ordered-list item. + state.addRichSpan( + CharLineOffset(0, 0), + CharLineOffset(0, state.textLines[0].length), + OrderedListSpanStyle, + ) + + // Copy only the middle of the list item, so the span starts BEFORE the copy + // range — the partial-overlap case that produced negative relative offsets. + val copyRange = TextEditorRange( + start = CharLineOffset(0, 3), + end = CharLineOffset(0, 8), + ) + val copiedText = state.getTextInRange(copyRange) + state.copyRichSpans(copyRange) + + val pasteLine = state.textLines.lastIndex + state.cursor.updatePosition(CharLineOffset(pasteLine, 0)) + state.preserveCopiedRichSpansThroughNextEdit() + state.insertStringAtCursor(copiedText) + state.pasteRichSpans(CharLineOffset(pasteLine, 0), copiedText) + + // Every span must be well-formed (start <= end) and in-bounds. + state.richSpanManager.getAllRichSpans().forEach { span -> + assertTrue( + span.range.start <= span.range.end, + "span start ${span.range.start} must not be after end ${span.range.end}", + ) + assertTrue( + span.range.start.line in state.textLines.indices && + span.range.end.line in state.textLines.indices, + "span lines must be in bounds: ${span.range}", + ) + assertTrue( + span.range.start.char >= 0 && + span.range.start.char <= state.textLines[span.range.start.line].length && + span.range.end.char >= 0 && + span.range.end.char <= state.textLines[span.range.end.line].length, + "span chars must be in bounds: ${span.range}", + ) + } + } + + @Test + fun `an edit between copy and paste prevents stale spans from applying`() = runTest { + val state = createState("item\nblank") + state.addRichSpan( + CharLineOffset(0, 0), + CharLineOffset(0, state.textLines[0].length), + OrderedListSpanStyle, + ) + + val copyRange = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, state.textLines[0].length), + ) + val copiedText = state.getTextInRange(copyRange) + state.copyRichSpans(copyRange) + + // An unrelated edit happens after the copy. This invalidates the buffer so + // the same text pasted afterwards (e.g. identical content from another app) + // does not pick up the in-editor spans. + state.cursor.updatePosition(CharLineOffset(1, state.textLines[1].length)) + state.insertStringAtCursor(AnnotatedString("X")) + + val pasteLine = state.textLines.lastIndex + state.cursor.updatePosition(CharLineOffset(pasteLine, state.textLines[pasteLine].length)) + state.insertStringAtCursor(copiedText) + state.pasteRichSpans(CharLineOffset(pasteLine, 0), copiedText) + + // Only the original list line keeps its span; the stale buffer was dropped. + assertEquals(listOf(0), state.orderedLines()) + } + @Test fun `paste does not apply stale spans when pasted text differs from what was copied`() = runTest { val state = createState("item\nblank")