Skip to content

Commit be60d82

Browse files
committed
Update
1 parent d9a8cb2 commit be60d82

7 files changed

Lines changed: 238 additions & 9 deletions

File tree

app/src/main/java/com/tool/tree/TextEditorActivity.kt

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,27 @@ package com.tool.tree
22

33
import android.content.Context
44
import android.content.Intent
5+
import android.content.res.ColorStateList
56
import android.os.Bundle
7+
import android.os.Handler
8+
import android.os.Looper
69
import android.text.Editable
710
import android.text.TextWatcher
11+
import android.util.TypedValue
12+
import android.view.Gravity
813
import android.view.Menu
914
import android.view.MenuItem
1015
import android.view.View
1116
import android.view.ViewGroup
1217
import android.widget.HorizontalScrollView
18+
import android.widget.ImageButton
1319
import android.widget.Toast
1420
import androidx.activity.addCallback
1521
import androidx.appcompat.app.AppCompatActivity
1622
import androidx.appcompat.widget.Toolbar
1723
import androidx.core.view.ViewCompat
1824
import androidx.core.view.WindowInsetsCompat
25+
import androidx.core.widget.ImageViewCompat
1926
import androidx.lifecycle.lifecycleScope
2027
import com.omarea.common.shared.FileWrite
2128
import com.omarea.common.shell.KeepShellPublic
@@ -57,6 +64,11 @@ class TextEditorActivity : AppCompatActivity() {
5764
"bash" to "bash"
5865
)
5966

67+
// Số bước Undo tối đa được giữ lại, và thời gian tạm dừng gõ (ms) để "chốt" một bước
68+
// Undo mới — gộp các ký tự gõ liên tục thành một bước duy nhất.
69+
private const val UNDO_HISTORY_LIMIT = 200
70+
private const val UNDO_DEBOUNCE_MS = 700L
71+
6072
fun start(
6173
context: Context,
6274
file: String,
@@ -92,6 +104,20 @@ class TextEditorActivity : AppCompatActivity() {
92104
private var syntaxHighlighter: SyntaxHighlighter? = null
93105
private val progressBarDialog by lazy { ProgressBarDialog(this) }
94106

107+
// --- Undo / Redo ---
108+
// Lưu lại toàn bộ nội dung tại mỗi "đợt" chỉnh sửa (gộp các ký tự gõ liên tục thành một
109+
// bước, thay vì lưu theo từng ký tự) để nút Undo/Redo hoạt động tự nhiên như trình soạn
110+
// thảo thông thường. EditText mặc định của Android không có sẵn undo/redo công khai nên
111+
// phải tự quản lý 2 stack này.
112+
private val undoStack = ArrayDeque<String>()
113+
private val redoStack = ArrayDeque<String>()
114+
private var pendingUndoSnapshot: String? = null
115+
private var isApplyingHistory = false
116+
private var undoButton: ImageButton? = null
117+
private var redoButton: ImageButton? = null
118+
private val undoHandler = Handler(Looper.getMainLooper())
119+
private val commitPendingUndoRunnable = Runnable { commitPendingUndoSnapshot() }
120+
95121
override fun onCreate(savedInstanceState: Bundle?) {
96122
super.onCreate(savedInstanceState)
97123
ThemeModeState.switchTheme(this)
@@ -101,9 +127,12 @@ class TextEditorActivity : AppCompatActivity() {
101127

102128
val toolbar = findViewById<View>(R.id.toolbar) as Toolbar
103129
setSupportActionBar(toolbar)
104-
supportActionBar?.setHomeButtonEnabled(true)
105-
supportActionBar?.setDisplayHomeAsUpEnabled(true)
106-
toolbar.setNavigationOnClickListener { attemptClose() }
130+
// Bỏ nút back (mũi tên) trên toolbar theo yêu cầu — việc thoát trang giờ thực hiện qua
131+
// mục "Thoát" trong menu (xem onOptionsItemSelected). Nút back cứng/gesture của hệ
132+
// thống vẫn hoạt động bình thường nhờ onBackPressedDispatcher bên dưới.
133+
supportActionBar?.setHomeButtonEnabled(false)
134+
supportActionBar?.setDisplayHomeAsUpEnabled(false)
135+
setupUndoRedoButtons(toolbar)
107136

108137
onBackPressedDispatcher.addCallback(this) { attemptClose() }
109138

@@ -124,12 +153,14 @@ class TextEditorActivity : AppCompatActivity() {
124153

125154
applyWrapState()
126155
setupCursorAutoScroll()
156+
setupUndoRedoTracking()
127157
loadFileContent()
128158
}
129159

130160
override fun onDestroy() {
131161
syntaxHighlighter?.detach()
132162
syntaxHighlighter = null
163+
undoHandler.removeCallbacksAndMessages(null)
133164
super.onDestroy()
134165
}
135166

@@ -227,6 +258,142 @@ class TextEditorActivity : AppCompatActivity() {
227258
syntaxHighlighter?.attach()
228259
}
229260

261+
/**
262+
* Tạo 2 nút Undo/Redo và gắn vào bên trái của toolbar (thay cho vị trí nút back đã bỏ).
263+
*/
264+
private fun setupUndoRedoButtons(toolbar: Toolbar) {
265+
val tint = ColorStateList.valueOf(resolveThemeColor(android.R.attr.textColorPrimary))
266+
val buttonSize = (48 * resources.displayMetrics.density).toInt()
267+
val iconPadding = (12 * resources.displayMetrics.density).toInt()
268+
269+
fun makeButton(iconRes: Int, descRes: Int): ImageButton {
270+
return ImageButton(this).apply {
271+
setImageResource(iconRes)
272+
contentDescription = getString(descRes)
273+
setPadding(iconPadding, iconPadding, iconPadding, iconPadding)
274+
val outValue = TypedValue()
275+
theme.resolveAttribute(
276+
android.R.attr.selectableItemBackgroundBorderless,
277+
outValue,
278+
true
279+
)
280+
setBackgroundResource(outValue.resourceId)
281+
ImageViewCompat.setImageTintList(this, tint)
282+
layoutParams = Toolbar.LayoutParams(buttonSize, buttonSize).apply {
283+
gravity = Gravity.START or Gravity.CENTER_VERTICAL
284+
}
285+
}
286+
}
287+
288+
undoButton = makeButton(R.drawable.ic_editor_undo, R.string.editor_undo).also {
289+
it.setOnClickListener { performUndo() }
290+
toolbar.addView(it)
291+
}
292+
redoButton = makeButton(R.drawable.ic_editor_redo, R.string.editor_redo).also {
293+
it.setOnClickListener { performRedo() }
294+
toolbar.addView(it)
295+
}
296+
refreshUndoRedoButtons()
297+
}
298+
299+
private fun resolveThemeColor(attr: Int): Int {
300+
val typedValue = TypedValue()
301+
theme.resolveAttribute(attr, typedValue, true)
302+
return typedValue.data
303+
}
304+
305+
/**
306+
* Theo dõi nội dung để phục vụ Undo/Redo. Các thay đổi gõ liên tục (không dừng quá
307+
* [UNDO_DEBOUNCE_MS]) được gộp thành một bước, để Undo/Redo hoạt động theo "đợt chỉnh sửa"
308+
* thay vì theo từng ký tự.
309+
*/
310+
private fun setupUndoRedoTracking() {
311+
binding.editorContent.addTextChangedListener(object : TextWatcher {
312+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
313+
if (isApplyingHistory) return
314+
if (pendingUndoSnapshot == null) {
315+
pendingUndoSnapshot = s?.toString().orEmpty()
316+
}
317+
}
318+
319+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
320+
321+
override fun afterTextChanged(s: Editable?) {
322+
if (isApplyingHistory) return
323+
redoStack.clear()
324+
undoHandler.removeCallbacks(commitPendingUndoRunnable)
325+
undoHandler.postDelayed(commitPendingUndoRunnable, UNDO_DEBOUNCE_MS)
326+
refreshUndoRedoButtons()
327+
}
328+
})
329+
}
330+
331+
private fun commitPendingUndoSnapshot() {
332+
val snapshot = pendingUndoSnapshot ?: return
333+
pendingUndoSnapshot = null
334+
val current = binding.editorContent.text?.toString().orEmpty()
335+
if (snapshot == current) return
336+
337+
if (undoStack.size >= UNDO_HISTORY_LIMIT) {
338+
undoStack.removeFirst()
339+
}
340+
undoStack.addLast(snapshot)
341+
refreshUndoRedoButtons()
342+
}
343+
344+
private fun performUndo() {
345+
undoHandler.removeCallbacks(commitPendingUndoRunnable)
346+
val current = binding.editorContent.text?.toString().orEmpty()
347+
348+
val target: String
349+
val pending = pendingUndoSnapshot
350+
if (pending != null) {
351+
// Đang có một đợt gõ chưa kịp "chốt" vào undoStack — hoàn tác thẳng về mốc đó.
352+
target = pending
353+
pendingUndoSnapshot = null
354+
} else {
355+
if (undoStack.isEmpty()) return
356+
target = undoStack.removeLast()
357+
}
358+
359+
redoStack.addLast(current)
360+
applyHistoryText(target)
361+
refreshUndoRedoButtons()
362+
}
363+
364+
private fun performRedo() {
365+
if (redoStack.isEmpty()) return
366+
undoHandler.removeCallbacks(commitPendingUndoRunnable)
367+
pendingUndoSnapshot = null
368+
369+
val current = binding.editorContent.text?.toString().orEmpty()
370+
val target = redoStack.removeLast()
371+
372+
if (undoStack.size >= UNDO_HISTORY_LIMIT) {
373+
undoStack.removeFirst()
374+
}
375+
undoStack.addLast(current)
376+
applyHistoryText(target)
377+
refreshUndoRedoButtons()
378+
}
379+
380+
private fun applyHistoryText(text: String) {
381+
isApplyingHistory = true
382+
val editText = binding.editorContent
383+
editText.setText(text)
384+
editText.setSelection(text.length.coerceAtLeast(0))
385+
isApplyingHistory = false
386+
}
387+
388+
private fun refreshUndoRedoButtons() {
389+
val canUndo = undoStack.isNotEmpty() || pendingUndoSnapshot != null
390+
val canRedo = redoStack.isNotEmpty()
391+
undoButton?.isEnabled = canUndo
392+
undoButton?.alpha = if (canUndo) 1f else 0.4f
393+
redoButton?.isEnabled = canRedo
394+
redoButton?.alpha = if (canRedo) 1f else 0.4f
395+
}
396+
230397
private fun loadFileContent() {
231398
progressBarDialog.showDialog(getString(R.string.please_wait))
232399
lifecycleScope.launch(Dispatchers.IO) {
@@ -246,7 +413,14 @@ class TextEditorActivity : AppCompatActivity() {
246413
progressBarDialog.hideDialog()
247414
isNewFile = newFile
248415
savedContent = content
416+
isApplyingHistory = true
249417
binding.editorContent.setText(content)
418+
isApplyingHistory = false
419+
undoHandler.removeCallbacks(commitPendingUndoRunnable)
420+
pendingUndoSnapshot = null
421+
undoStack.clear()
422+
redoStack.clear()
423+
refreshUndoRedoButtons()
250424
binding.editorContent.hint = if (newFile) {
251425
getString(R.string.editor_hint_new_file)
252426
} else {
@@ -307,7 +481,11 @@ class TextEditorActivity : AppCompatActivity() {
307481
// Chỉ đổi setHorizontallyScrolling()/layoutParams thôi thì TextView không tự dựng lại
308482
// Layout nội bộ theo chiều rộng mới (văn bản đã dàn trang trước đó bị giữ nguyên), nên
309483
// phải set lại text để buộc nó dựng lại layout rồi khôi phục vị trí con trỏ.
484+
// Đây không phải là một thao tác chỉnh sửa nội dung thật sự nên không được tính vào
485+
// lịch sử Undo/Redo.
486+
isApplyingHistory = true
310487
editText.setText(currentText)
488+
isApplyingHistory = false
311489
editText.setSelection(
312490
cursorStart.coerceAtMost(currentText.length),
313491
cursorEnd.coerceAtMost(currentText.length)
@@ -366,6 +544,10 @@ class TextEditorActivity : AppCompatActivity() {
366544
applyWrapState()
367545
true
368546
}
547+
R.id.editor_menu_exit -> {
548+
attemptClose()
549+
true
550+
}
369551
android.R.id.home -> {
370552
attemptClose()
371553
true

app/src/main/java/com/tool/tree/ui/SyntaxHighlighterFactory.kt

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ abstract class BaseSyntaxHighlighter(
6060
private var watcher: TextWatcher? = null
6161
private var isHighlighting = false
6262

63+
companion object {
64+
private const val MAX_HIGHLIGHT_LENGTH = 300_000
65+
}
66+
6367
protected val editText: EditText?
6468
get() = editTextRef.get()
6569

@@ -92,9 +96,18 @@ abstract class BaseSyntaxHighlighter(
9296
val text = et.text ?: return
9397
if (isHighlighting) return
9498

99+
if (text.length > MAX_HIGHLIGHT_LENGTH) {
100+
// File quá lớn: tô màu lại toàn bộ trên mỗi lần gõ sẽ làm treo UI thread (ANR).
101+
// Bỏ qua tô màu cú pháp cho các file lớn, người dùng vẫn gõ/sửa bình thường.
102+
return
103+
}
104+
95105
isHighlighting = true
96106
try {
97107
applyHighlight(text)
108+
} catch (_: Throwable) {
109+
// Không để một lỗi ở bước tô màu cú pháp (vd. regex gặp nội dung bất thường)
110+
// làm crash toàn bộ app — bỏ qua lần tô màu này là đủ, nội dung gõ vẫn giữ nguyên.
98111
} finally {
99112
isHighlighting = false
100113
}
@@ -173,13 +186,18 @@ class ShellSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
173186
}
174187

175188
// Strings: double quotes, single quotes, backticks
176-
Regex(""""(?:\\.|[^"])*"""").findAll(s).forEach {
189+
// Lưu ý: nhánh escape (\\.) và nhánh "mọi ký tự khác" phải KHÔNG được chồng lấn nhau
190+
// (cả hai cùng khớp được ký tự '\') — nếu chồng lấn, một chuỗi chưa đóng (thiếu dấu
191+
// đóng ") kèm nhiều dấu '\' sẽ khiến regex engine backtrack theo cấp số nhân và ném
192+
// StackOverflowError, làm crash app ngay khi người dùng đang gõ dở. Loại bỏ '\\' khỏi
193+
// lớp ký tự [^"\\] để hai nhánh không còn khớp trùng nhau nữa.
194+
Regex(""""(?:\\.|[^"\\])*"""").findAll(s).forEach {
177195
color(text, it.range.first, it.range.last + 1, stringColor())
178196
}
179197
Regex("""'(?:[^']*)'""").findAll(s).forEach {
180198
color(text, it.range.first, it.range.last + 1, stringColor())
181199
}
182-
Regex("""`(?:\\.|[^`])*`""").findAll(s).forEach {
200+
Regex("""`(?:\\.|[^`\\])*`""").findAll(s).forEach {
183201
color(text, it.range.first, it.range.last + 1, stringColor())
184202
}
185203

@@ -254,8 +272,8 @@ class XmlSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
254272
color(text, it.range.first, it.range.last + 1, builtinColor())
255273
}
256274

257-
// Attribute values
258-
Regex(""""(?:\\.|[^"])*"""").findAll(s).forEach {
275+
// Attribute values (xem ghi chú về backtracking ở ShellSyntaxHighlighter phía trên)
276+
Regex(""""(?:\\.|[^"\\])*"""").findAll(s).forEach {
259277
color(text, it.range.first, it.range.last + 1, stringColor())
260278
}
261279

@@ -300,8 +318,8 @@ class PropSyntaxHighlighter(editText: EditText) : BaseSyntaxHighlighter(
300318
}
301319
}
302320

303-
// Quoted values
304-
Regex(""""(?:\\.|[^"])*"""").findAll(s).forEach {
321+
// Quoted values (xem ghi chú về backtracking ở ShellSyntaxHighlighter phía trên)
322+
Regex(""""(?:\\.|[^"\\])*"""").findAll(s).forEach {
305323
color(text, it.range.first, it.range.last + 1, stringColor())
306324
}
307325

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<path
7+
android:fillColor="#FFFFFFFF"
8+
android:pathData="M18.4,10.6C16.55,8.99 14.15,8 11.5,8c-4.65,0 -8.58,3.03 -9.96,7.22l2.36,0.78c1.04,-3.19 4.05,-5.5 7.6,-5.5 1.95,0 3.73,0.72 5.12,1.88L13,16h9v-9L18.4,10.6z" />
9+
</vector>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<path
7+
android:fillColor="#FFFFFFFF"
8+
android:pathData="M12.5,8c-2.65,0 -5.05,0.99 -6.9,2.6L2,7v9h9l-3.62,-3.62c1.39,-1.16 3.16,-1.88 5.12,-1.88 3.54,0 6.55,2.31 7.6,5.5l2.37,-0.78C21.08,11.03 17.15,8 12.5,8z" />
9+
</vector>

app/src/main/res/menu/menu_text_editor.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@
1818
android:orderInCategory="20"
1919
android:title="@string/editor_toggle_wrap"
2020
app:showAsAction="never" />
21+
<item
22+
android:id="@+id/editor_menu_exit"
23+
android:orderInCategory="30"
24+
android:title="@string/editor_exit"
25+
app:showAsAction="never" />
2126
</menu>

app/src/main/res/values-vi/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,5 +154,8 @@ Nếu đồng ý hãy ấn vào nút xác nhận để tiếp tục."</string>
154154
<string name="editor_save_fail">Lưu file thất bại</string>
155155
<string name="editor_toggle_wrap">Ngắt dòng</string>
156156
<string name="editor_run_test">Chạy thử nghiệm</string>
157+
<string name="editor_exit">Thoát</string>
158+
<string name="editor_undo">Hoàn tác</string>
159+
<string name="editor_redo">Làm lại</string>
157160

158161
</resources>

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,5 +168,8 @@ If you agree, press the confirm button to continue."</string>
168168
<string name="editor_save_fail">Failed to save the file</string>
169169
<string name="editor_toggle_wrap">Word wrap</string>
170170
<string name="editor_run_test">Run test</string>
171+
<string name="editor_exit">Exit</string>
172+
<string name="editor_undo">Undo</string>
173+
<string name="editor_redo">Redo</string>
171174

172175
</resources>

0 commit comments

Comments
 (0)