Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,20 @@ 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)
}
}
}

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)
Expand All @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ data class PreservedRichSpan(
val style: RichSpanStyle
)

data class CopiedRichSpans(
val text: String,
val spans: List<PreservedRichSpan>
)

data class OperationMetadata(
val deletedText: AnnotatedString? = null,
val deletedSpans: List<RichSpan> = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int> =
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())
}
}
Loading