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..6fc816c 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,10 @@ 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.preserveCopiedRichSpansThroughNextEdit() state.selector.deleteSelection() scope.launch { ClipboardHelper.setText(clipboard, selectedText) @@ -188,11 +191,14 @@ internal class TextEditorKeyCommandHandler { scope.launch { 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 { 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/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 01e678d..fabc7a2 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,17 @@ 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 + + // 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.) @@ -860,6 +871,90 @@ 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) { + // 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(clampedStart, range.start), + relativeEnd = getRelativePosition(clampedEnd, range.start), + style = span.style + ) + } + copiedRichSpans = if (preserved.isEmpty()) { + null + } else { + CopiedRichSpans(text = getStringInRange(range), spans = preserved) + } + } + + /** + * 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. 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 + 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..fd28965 --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/spans/RichSpanClipboardTest.kt @@ -0,0 +1,171 @@ +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, 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) + + // 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 `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") + 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()) + } +}