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 @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -378,11 +379,11 @@ class RichSpanManager(
}
}

private fun handleStyleSpan(
private fun handleSpanOnly(
updatedSpans: MutableSet<RichSpan>,
span: RichSpan
) {
// Noop for StyleSpan
// Span-only operations leave existing spans in place.
updatedSpans.add(span)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,8 +25,11 @@ class TextEditManager(private val state: TextEditorState) {
val editOperations: SharedFlow<TextEditOperation> = _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()
}

Expand All @@ -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)
Expand Down Expand Up @@ -510,13 +515,27 @@ 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) {
is TextEditOperation.Insert -> undoInsert(entry.operation, entry)
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)
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading