How do I clear undo/redo history in Jetpack Compose TextField (or BasicTextField)

168 Views Asked by At

I'm developing an desktop app in Compose. Basically I have a text editor where I can open different text files. The issue is that TextField keeps it's undo/redo state internally (as far as I know). So when I open file A, then file B, and press Ctrl+Z, file B contents change to file A. I'd like to clear undo/redo state when I open new file, or even better, keep that state per file path (but that's extension of my requirements).

My current approach to this (Disabling Ctrl + Z/Ctrl + Y)

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TextEditorArea(
    textFile: File,
    fontSize: TextUnit,
    fileContentsCache: MutableMap<File, String>,
    modifier: Modifier = Modifier
) {
    var textContent by remember { mutableStateOf(TextFieldValue("")) }
    var searchQuery by remember { mutableStateOf(TextFieldValue("")) }
    val coroutineScope = rememberCoroutineScope()
    val searchFocusRequester = remember { FocusRequester() }

    var activeFile by remember { mutableStateOf<File?>(null) }

    // Initialize and update active file and text content
    LaunchedEffect(key1 = textFile) {
        activeFile = textFile
        val loadedText = fileContentsCache[textFile] ?: textFile.readText()
        textContent = TextFieldValue(AnnotatedString(loadedText))
    }

    // Handle text changes with debounced saving
    DisposableEffect(key1 = textContent.text, key2 = activeFile) {
        if (activeFile == null) return@DisposableEffect onDispose { }

        val job = coroutineScope.launch {
            delay(250) // Debounce time for saving
            if (activeFile != null && textContent.text != fileContentsCache[activeFile]) {
                activeFile!!.writeText(textContent.text)
                fileContentsCache[activeFile!!] = textContent.text
            }
        }

        onDispose {
            job.cancel()
            // Immediate save when disposing (e.g., switching files or closing)
            coroutineScope.launch {
                if (activeFile != null && textContent.text != fileContentsCache[activeFile]) {
                    activeFile!!.writeText(textContent.text)
                    fileContentsCache[activeFile!!] = textContent.text
                }
            }
        }
    }


    // Update highlights based on search query
    val annotatedText by derivedStateOf {
        buildAnnotatedString {
            append(textContent.text)
            findSearchHits(textContent.text, searchQuery.text).forEach { range ->
                addStyle(
                    style = SpanStyle(background = Color.Yellow),
                    start = range.start,
                    end = range.end
                )
            }
        }
    }

    Column(modifier = modifier) {
        TextField(
            value = searchQuery,
            onValueChange = { query ->
                searchQuery = query
            },
            label = { Text("Search") },
            singleLine = true,
            modifier = Modifier.fillMaxWidth()
                .focusRequester(searchFocusRequester),
        )

        BasicTextField(
            value = TextFieldValue(annotatedText, textContent.selection, textContent.composition),
            onValueChange = { newValue ->
                // Update textContent while preserving user's selection and composition
                textContent = newValue
            },
            textStyle = TextStyle.Default.copy(
                fontSize = fontSize,
                fontFamily = FontFamily.Monospace
            ),
            modifier = Modifier
                .fillMaxSize()
                .padding(4.dp)
                .verticalScroll(rememberScrollState())
                .onPreviewKeyEvent { keyEvent ->
                    if (keyEvent.key == Key.F && keyEvent.isCtrlPressed && keyEvent.type == KeyEventType.KeyDown) {
                        searchFocusRequester.requestFocus()
                        searchQuery = TextFieldValue(
                            searchQuery.text,
                            selection = TextRange(0, searchQuery.text.length)
                        )
                        true // Event consumed
                    } else if (keyEvent.type == KeyEventType.KeyDown &&
                        (keyEvent.isCtrlPressed && (keyEvent.key == Key.Z || keyEvent.key == Key.Y))
                    ) {
                        true // Disable Ctrl + Z / Ctrl + Y
                    } else false
                },
            decorationBox = { innerTextField ->
                Box(modifier = Modifier.padding(4.dp)) { innerTextField() }
            }
        )
    }
}

Any ideas?

2

There are 2 best solutions below

3
Thracian On

One alternative could be using one list for TextFieldValue or String and another one for redo.

Result

enter image description here

Implementation

class RedoState {

    private val defaultValue = TextFieldValue()

    private val textFieldValues = mutableListOf<TextFieldValue>()
    private val undoneTextFieldValues = mutableListOf<TextFieldValue>()

    var textFieldValue: TextFieldValue by mutableStateOf(defaultValue)

    fun redoText() {
        if (undoneTextFieldValues.isNotEmpty()) {
            val lastItem = undoneTextFieldValues.last()
            textFieldValue = undoneTextFieldValues.removeLast()
            textFieldValues.add(lastItem)
        }
    }

    fun undoText() {
        if (textFieldValues.isNotEmpty()) {
            val lastItem = textFieldValues.last()
            textFieldValues.removeLast()
            
            textFieldValues.lastOrNull()?.let {
                textFieldValue = it
            }?:run {
                textFieldValue = defaultValue
            }
            undoneTextFieldValues.add(lastItem)
        }
    }

    fun update(newValue: TextFieldValue) {
        if (textFieldValues.isEmpty() || (textFieldValue.text != newValue.text)
        ) {
            textFieldValues.add(newValue)
        }
        textFieldValue = newValue

    }
}

TextField that updates or gets values from it.

@Composable
fun EditableTextField(
    redoState: RedoState = remember {
        RedoState()
    }
) {

    val textFieldValue = redoState.textFieldValue

    TextField(
        value = textFieldValue,
        onValueChange = {
            redoState.update(it)
        }
    )
}

Usage

@Preview
@Composable
fun UndoRedoTest() {
    Column(
        modifier = Modifier.fillMaxSize().padding(32.dp)
    ) {
        val redoState = remember {
            RedoState()
        }

        EditableTextField(redoState)

        Row {
            Button(
                modifier = Modifier.weight(1f),
                onClick = {
                    redoState.undoText()
                }
            ) {
                Text("Undo")
            }

            Spacer(Modifier.width(16.dp))

            Button(
                modifier = Modifier.weight(1f),
                onClick = {
                    redoState.redoText()
                }
            ) {
                Text("Redo")
            }
        }
    }
}
4
Phil Dukhov On

The problem arises because you're using a single composable with different data. By default, the view will reuse anything that isn't explicitly marked to be reset when new data arrives - and that's usually expected.

You could have passed the key parameter to all your remember/effects to reset them one by one, but the easiest way to let Compose know that view shouldn't be reused at all is to wrap it with key:

key(textFile) {
    TextEditorArea(textFile, ...)
}