diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt index 474b2fe..6e8ad89 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt @@ -240,15 +240,17 @@ class MarkdownExtension( val processedMarkdown = processedLines.joinToString("\n") val annotatedString = processedMarkdown.toAnnotatedStringFromMarkdown(markdownConfiguration) editorState.setText(annotatedString) + // Direct manager calls: importing a document populates spans as part of + // loading content, not as a user edit, so it must not enter undo history. hrLineIndices.forEach { lineIdx -> - editorState.addRichSpan( + editorState.richSpanManager.addRichSpan( start = CharLineOffset(lineIdx, 0), end = CharLineOffset(lineIdx, HR_PLACEHOLDER.length), style = HorizontalRuleSpanStyle, ) } imageLines.forEach { (lineIdx, style) -> - editorState.addRichSpan( + editorState.richSpanManager.addRichSpan( start = CharLineOffset(lineIdx, 0), end = CharLineOffset(lineIdx, IMAGE_PLACEHOLDER.length), style = style, diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt index 2275378..1bd6b0e 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt @@ -157,7 +157,10 @@ internal fun TextEditorState.applyLineBlock(line: Int, block: LineBlockStyle) { } } updateLine(line, rebuilt) - addRichSpan( + // Direct manager call: the line-block change is driven by `updateLine` (which + // isn't history-tracked), so recording only the span would make undo leave a + // half-applied block. Block-level undo is out of scope here. + richSpanManager.addRichSpan( start = CharLineOffset(line, 0), end = CharLineOffset(line, existing.length), style = block.spanStyle, @@ -174,7 +177,7 @@ internal fun TextEditorState.demoteLineBlock(line: Int, block: LineBlockStyle) { val spans = richSpanManager.getAllRichSpans() .filter { it.style === block.spanStyle && it.range.start.line == line } if (spans.isEmpty()) return - spans.forEach { removeRichSpan(it) } + spans.forEach { richSpanManager.removeRichSpan(it) } val rebuilt = buildAnnotatedString { append(existing.text) existing.spanStyles.forEach { range -> diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt index fe62a87..740bc6c 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt @@ -58,7 +58,8 @@ class RichSpanManager( span ) is TextEditOperation.Replace -> handleReplace(operation, updatedSpans, span) - is TextEditOperation.StyleSpan -> handleStyleSpan(updatedSpans, span) + is TextEditOperation.StyleSpan -> handleSpanOnly(updatedSpans, span) + is TextEditOperation.RichSpan -> handleSpanOnly(updatedSpans, span) } } } @@ -378,11 +379,11 @@ class RichSpanManager( } } - private fun handleStyleSpan( + private fun handleSpanOnly( updatedSpans: MutableSet, span: RichSpan ) { - // Noop for StyleSpan + // Span-only operations leave existing spans in place. updatedSpans.add(span) } 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 05ebd79..2fb31d1 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.text.buildAnnotatedString import com.darkrockstudios.texteditor.CharLineOffset import com.darkrockstudios.texteditor.TextEditorRange import com.darkrockstudios.texteditor.annotatedstring.splitAnnotatedString +import com.darkrockstudios.texteditor.richstyle.RichSpanStyle import com.darkrockstudios.texteditor.utils.appendAnnotatedStrings import com.darkrockstudios.texteditor.utils.buildAnnotatedStringWithSpans import com.darkrockstudios.texteditor.utils.mergeAnnotatedStrings @@ -24,8 +25,11 @@ class TextEditManager(private val state: TextEditorState) { val editOperations: SharedFlow = _editOperations fun applyOperation(operation: TextEditOperation, addToHistory: Boolean = true) { - // Selection offsets must not outlive a content mutation - if (operation !is TextEditOperation.StyleSpan && state.selector.selection != null) { + // Selection offsets must not outlive a content mutation. Span operations + // leave the text untouched, so they keep the selection. + val isSpanOperation = operation is TextEditOperation.StyleSpan || + operation is TextEditOperation.RichSpan + if (!isSpanOperation && state.selector.selection != null) { state.selector.clearSelection() } @@ -34,6 +38,7 @@ class TextEditManager(private val state: TextEditorState) { is TextEditOperation.Delete -> applyDelete(addToHistory, operation) is TextEditOperation.Replace -> applyReplace(addToHistory, operation) is TextEditOperation.StyleSpan -> applyStyleOperation(operation) + is TextEditOperation.RichSpan -> applyRichSpanOperation(operation) } state.cursor.updatePosition(operation.cursorAfter) @@ -510,6 +515,19 @@ class TextEditManager(private val state: TextEditorState) { return null } + private fun applyRichSpanOperation(operation: TextEditOperation.RichSpan): OperationMetadata? { + if (operation.isAdd) { + state.richSpanManager.addRichSpan(operation.range, operation.style) + } else { + state.richSpanManager.removeRichSpan( + operation.range.start, + operation.range.end, + operation.style + ) + } + return null + } + fun undo() { history.undo()?.let { entry -> when (entry.operation) { @@ -517,6 +535,7 @@ class TextEditManager(private val state: TextEditorState) { is TextEditOperation.Delete -> undoDelete(entry, entry.operation) is TextEditOperation.Replace -> undoReplace(entry.operation, entry) is TextEditOperation.StyleSpan -> undoStyleSpan(entry.operation) + is TextEditOperation.RichSpan -> undoRichSpan(entry.operation) } } } @@ -631,6 +650,18 @@ class TextEditManager(private val state: TextEditorState) { applyOperation(inverseOperation, addToHistory = false) } + private fun undoRichSpan(operation: TextEditOperation.RichSpan) { + val inverseOperation = TextEditOperation.RichSpan( + range = operation.range, + style = operation.style, + isAdd = !operation.isAdd, + cursorBefore = operation.cursorAfter, + cursorAfter = operation.cursorBefore + ) + + applyOperation(inverseOperation, addToHistory = false) + } + fun redo() { history.redo()?.let { entry -> applyOperation(entry.operation, addToHistory = false) @@ -658,7 +689,9 @@ class TextEditManager(private val state: TextEditorState) { preserved.relativeEnd.char ) - state.addRichSpan(startPos, endPos, preserved.style) + // Bypass the history-recording path: this restoration is itself part of + // an undo/redo and must not push a new edit onto the queue. + state.richSpanManager.addRichSpan(startPos, endPos, preserved.style) } } @@ -683,4 +716,26 @@ class TextEditManager(private val state: TextEditorState) { ) applyOperation(operation) } + + fun addRichSpan(textRange: TextEditorRange, style: RichSpanStyle) { + val operation = TextEditOperation.RichSpan( + range = textRange, + style = style, + isAdd = true, + cursorBefore = state.cursorPosition, + cursorAfter = state.cursorPosition // Keep cursor in same position + ) + applyOperation(operation) + } + + fun removeRichSpan(textRange: TextEditorRange, style: RichSpanStyle) { + val operation = TextEditOperation.RichSpan( + range = textRange, + style = style, + isAdd = false, + cursorBefore = state.cursorPosition, + cursorAfter = state.cursorPosition // Keep cursor in same position + ) + applyOperation(operation) + } } \ No newline at end of file diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt index e251993..7715c22 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import com.darkrockstudios.texteditor.CharLineOffset import com.darkrockstudios.texteditor.TextEditorRange +import com.darkrockstudios.texteditor.richstyle.RichSpanStyle sealed class TextEditOperation { abstract val cursorBefore: CharLineOffset @@ -149,4 +150,17 @@ sealed class TextEditOperation { state: TextEditorState ): CharLineOffset = offset // Style changes don't affect positions } + + data class RichSpan( + val range: TextEditorRange, + val style: RichSpanStyle, + val isAdd: Boolean, // true for add, false for remove + override val cursorBefore: CharLineOffset, + override val cursorAfter: CharLineOffset + ) : TextEditOperation() { + override fun transformOffset( + offset: CharLineOffset, + state: TextEditorState + ): CharLineOffset = offset // Rich span changes don't affect positions + } } \ No newline at end of file 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 fabc7a2..17c79b1 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt @@ -796,27 +796,30 @@ class TextEditorState( } fun addRichSpan(range: TextEditorRange, style: RichSpanStyle) { - richSpanManager.addRichSpan(range, style) + editManager.addRichSpan(range, style) updateBookKeeping() } fun addRichSpan(start: CharLineOffset, end: CharLineOffset, style: RichSpanStyle) { - richSpanManager.addRichSpan(start, end, style) + editManager.addRichSpan(TextEditorRange(start, end), style) updateBookKeeping() } fun addRichSpan(start: Int, end: Int, style: RichSpanStyle) { - richSpanManager.addRichSpan(start.toCharLineOffset(), end.toCharLineOffset(), style) + editManager.addRichSpan( + TextEditorRange(start.toCharLineOffset(), end.toCharLineOffset()), + style + ) updateBookKeeping() } fun removeRichSpan(start: CharLineOffset, end: CharLineOffset, style: RichSpanStyle) { - richSpanManager.removeRichSpan(start, end, style) + editManager.removeRichSpan(TextEditorRange(start, end), style) updateBookKeeping() } fun removeRichSpan(span: RichSpan) { - richSpanManager.removeRichSpan(span) + editManager.removeRichSpan(span.range, span.style) updateBookKeeping() } diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/RichSpanRedoTests.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/RichSpanRedoTests.kt new file mode 100644 index 0000000..1fae25e --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/RichSpanRedoTests.kt @@ -0,0 +1,96 @@ +package texteditmanager.redo + +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.TextEditOperation +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RichSpanRedoTests { + private lateinit var scope: TestScope + private lateinit var state: TextEditorState + + @BeforeTest + fun setup() { + scope = TestScope() + state = TextEditorState( + scope = scope.backgroundScope, + measurer = mockk(relaxed = true) + ) + } + + private fun hasOrderedListSpan() = state.richSpanManager.getAllRichSpans() + .any { it.style == OrderedListSpanStyle } + + @Test + fun `test redo add rich span re-applies it`() { + state.setText("Hello World") + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 11) + ) + state.addRichSpan(range, OrderedListSpanStyle) + state.undo() + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + + state.redo() + assertTrue(hasOrderedListSpan()) + } + + @Test + fun `test redo remove rich span re-removes it`() { + state.setText("Hello World") + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 11) + ) + state.addRichSpan(range, OrderedListSpanStyle) + state.removeRichSpan(range.start, range.end, OrderedListSpanStyle) + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + + state.undo() + assertTrue(hasOrderedListSpan()) + + state.redo() + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + } + + @Test + fun `test type char then add list then undo redo ends in correct state`() { + state.setText("Hello World") + + state.editManager.applyOperation( + TextEditOperation.Insert( + position = CharLineOffset(0, 11), + text = AnnotatedString("!"), + cursorBefore = CharLineOffset(0, 11), + cursorAfter = CharLineOffset(0, 12) + ) + ) + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 12) + ) + state.addRichSpan(range, OrderedListSpanStyle) + + // Undo the list: char stays, list gone + state.undo() + assertEquals("Hello World!", state.textLines[0].text) + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + + // Redo the list: char stays, list back + state.redo() + assertEquals("Hello World!", state.textLines[0].text) + assertTrue(hasOrderedListSpan()) + } +} diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/RichSpanUndoTests.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/RichSpanUndoTests.kt new file mode 100644 index 0000000..f23800a --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/RichSpanUndoTests.kt @@ -0,0 +1,96 @@ +package texteditmanager.undo + +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.TextEditOperation +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RichSpanUndoTests { + private lateinit var scope: TestScope + private lateinit var state: TextEditorState + + @BeforeTest + fun setup() { + scope = TestScope() + state = TextEditorState( + scope = scope.backgroundScope, + measurer = mockk(relaxed = true) + ) + } + + private fun hasOrderedListSpan() = state.richSpanManager.getAllRichSpans() + .any { it.style == OrderedListSpanStyle } + + @Test + fun `test undo add rich span removes only the span`() { + state.setText("Hello World") + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 11) + ) + state.addRichSpan(range, OrderedListSpanStyle) + + assertTrue(hasOrderedListSpan()) + + state.undo() + + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + assertEquals("Hello World", state.textLines[0].text) + } + + @Test + fun `test undo add rich span preserves prior edit`() { + state.setText("Hello World") + + // Prior edit: insert a character + state.editManager.applyOperation( + TextEditOperation.Insert( + position = CharLineOffset(0, 11), + text = AnnotatedString("!"), + cursorBefore = CharLineOffset(0, 11), + cursorAfter = CharLineOffset(0, 12) + ) + ) + assertEquals("Hello World!", state.textLines[0].text) + + // Now apply a rich span (the list) + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 12) + ) + state.addRichSpan(range, OrderedListSpanStyle) + assertTrue(hasOrderedListSpan()) + + // Undo should remove only the list, leaving the typed character intact + state.undo() + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + assertEquals("Hello World!", state.textLines[0].text) + } + + @Test + fun `test undo remove rich span restores it`() { + state.setText("Hello World") + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 11) + ) + state.addRichSpan(range, OrderedListSpanStyle) + assertTrue(hasOrderedListSpan()) + + state.removeRichSpan(range.start, range.end, OrderedListSpanStyle) + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + + state.undo() + assertTrue(hasOrderedListSpan()) + } +} diff --git a/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt b/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt index 803b0e3..1d787e0 100644 --- a/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt +++ b/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt @@ -322,6 +322,7 @@ class SpellCheckState( is TextEditOperation.Replace -> operation.range is TextEditOperation.StyleSpan -> null + is TextEditOperation.RichSpan -> null } range?.let {