You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何清除Jetpack Compose TextField/BasicTextField的撤销/重做历史

问题

用Jetpack Compose开发桌面端文本编辑器,支持多文件切换。现在碰到个棘手问题:BasicTextField自带的撤销/重做历史是全局共享的,打开文件A编辑后切到文件B,按Ctrl+Z会把B的内容还原成A的内容。目前临时把Ctrl+Z/Y快捷键禁用了,想实现两个目标:

  1. 打开新文件时自动清空当前的撤销/重做历史
  2. 每个文件单独保存自己的编辑历史状态

核心解决思路

  • 给每个文件单独存状态:把文本内容、撤销栈、重做栈这些和文件绑定,不用全局共享的状态。利用Compose的remember带key的特性,切换文件时自动重建对应状态。
  • 自己管撤销重做:放弃BasicTextField内置的逻辑,自己实现基于文件的历史栈管理,这样能精准控制每个文件的历史记录。

具体实现步骤

1. 封装文件编辑状态类

直接定义一个数据类,存每个文件的文本状态、撤销栈和重做栈:

data class FileEditState(
    var textFieldValue: TextFieldValue,
    val undoStack: Stack<TextFieldValue> = Stack(),
    val redoStack: Stack<TextFieldValue> = Stack()
)

2. 用Map维护多文件状态

在ViewModel或者父组件里存一个MutableMap<File, FileEditState>,专门放每个文件的编辑状态(放ViewModel里更稳妥,不会因为重组丢失):

val fileEditStates = mutableMapOf<File, FileEditState>()

3. 绑定文件与Compose状态

在TextEditorArea里,用remember(textFile)把状态和当前文件绑定,切换文件时自动重建状态:

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

    // 绑定当前文件的编辑状态,切换文件时自动初始化/加载
    val fileEditState = remember(textFile) {
        fileEditStates.getOrPut(textFile) {
            val initialText = fileContentsCache[textFile] ?: textFile.readText()
            FileEditState(TextFieldValue(AnnotatedString(initialText)))
        }
    }
    var textContent by remember(textFile) { mutableStateOf(fileEditState.textFieldValue) }

    // 文件切换时加载对应状态
    LaunchedEffect(key1 = textFile) {
        textContent = fileEditState.textFieldValue
    }

4. 接管文本变更与撤销重做逻辑

在BasicTextField的onValueChange里记录历史到当前文件的栈,同时处理Ctrl+Z/Y事件:

// 搜索高亮逻辑保留
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
            )
        }
    }
}

BasicTextField(
    value = TextFieldValue(
        annotatedText, 
        textContent.selection, 
        textContent.composition
    ),
    onValueChange = { newValue ->
        // 每次编辑时,把当前状态压入撤销栈,清空重做栈
        fileEditState.undoStack.push(textContent)
        fileEditState.redoStack.clear()
        textContent = newValue
        fileEditState.textFieldValue = newValue
    },
    textStyle = TextStyle.Default.copy(
        fontSize = fontSize,
        fontFamily = FontFamily.Monospace
    ),
    modifier = Modifier
        .fillMaxSize()
        .padding(4.dp)
        .verticalScroll(rememberScrollState())
        .onPreviewKeyEvent { keyEvent ->
            when {
                // 搜索快捷键逻辑保留
                keyEvent.key == Key.F 
                && keyEvent.isCtrlPressed 
                && keyEvent.type == KeyEventType.KeyDown -> {
                    searchFocusRequester.requestFocus()
                    searchQuery = TextFieldValue(
                        searchQuery.text,
                        selection = TextRange(0, searchQuery.text.length)
                    )
                    true
                }
                // 撤销操作
                keyEvent.isCtrlPressed 
                && keyEvent.key == Key.Z 
                && keyEvent.type == KeyEventType.KeyDown -> {
                    if (fileEditState.undoStack.isNotEmpty()) {
                        val previousState = fileEditState.undoStack.pop()
                        fileEditState.redoStack.push(textContent)
                        textContent = previousState
                        fileEditState.textFieldValue = previousState
                    }
                    true
                }
                // 重做操作
                keyEvent.isCtrlPressed 
                && keyEvent.key == Key.Y 
                && keyEvent.type == KeyEventType.KeyDown -> {
                    if (fileEditState.redoStack.isNotEmpty()) {
                        val nextState = fileEditState.redoStack.pop()
                        fileEditState.undoStack.push(textContent)
                        textContent = nextState
                        fileEditState.textFieldValue = nextState
                    }
                    true
                }
                else -> false
            }
        },
    decorationBox = { innerTextField ->
        Box(modifier = Modifier.padding(4.dp)) { innerTextField() }
    }
)

5. 优化保存逻辑

切换文件时确保当前文件的状态同步到fileEditStates,同时更新缓存和本地文件:

// 防抖保存逻辑
DisposableEffect(key1 = textContent.text, key2 = textFile) {
    val job = coroutineScope.launch {
        delay(250)
        if (textContent.text != fileContentsCache[textFile]) {
            textFile.writeText(textContent.text)
            fileContentsCache[textFile] = textContent.text
            fileEditStates[textFile]?.textFieldValue = textContent
        }
    }
    onDispose {
        job.cancel()
        coroutineScope.launch {
            if (textContent.text != fileContentsCache[textFile]) {
                textFile.writeText(textContent.text)
                fileContentsCache[textFile] = textContent.text
                fileEditStates[textFile]?.textFieldValue = textContent
            }
        }
    }
}

内容的提问来源于stack exchange,提问作者xinaiz

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 15:24:59