Skip to content
Closed
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
159 changes: 132 additions & 27 deletions AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote
import com.ichi2.anki.noteeditor.CustomToolbarButton
import com.ichi2.anki.noteeditor.FieldState
import com.ichi2.anki.noteeditor.FieldState.FieldChangeType
import com.ichi2.anki.noteeditor.NoteEditorActionsListener
import com.ichi2.anki.noteeditor.NoteEditorLauncher
import com.ichi2.anki.noteeditor.Toolbar
import com.ichi2.anki.noteeditor.Toolbar.TextFormatListener
Expand Down Expand Up @@ -188,6 +189,7 @@ import timber.log.Timber
import java.io.File
import java.util.LinkedList
import java.util.Locale
import java.util.Stack
import java.util.function.Consumer
import kotlin.math.max
import kotlin.math.min
Expand Down Expand Up @@ -215,7 +217,8 @@ class NoteEditorFragment :
BaseSnackbarBuilderProvider,
DispatchKeyEventListener,
MenuProvider,
ShortcutGroupProvider {
ShortcutGroupProvider,
NoteEditorActionsListener {
/** Whether any change are saved. E.g. multimedia, new card added, field changed and saved. */
private var changed = false
private var isTagsEdited = false
Expand Down Expand Up @@ -279,6 +282,12 @@ class NoteEditorFragment :
private val fieldState = FieldState.fromEditor(this)
private lateinit var toolbar: Toolbar

// indicates changes to noteEditorActionListener
private val undoStacks: MutableMap<Int, Stack<String>> = mutableMapOf()
private val redoStacks: MutableMap<Int, Stack<String>> = mutableMapOf()
private var currentActiveFieldIn: Int? = null
private var isPerformingUndoRedo = false

// Use the same HTML if the same image is pasted multiple times.
private var pastedImageCache: HashMap<String, String> = HashMap()

Expand Down Expand Up @@ -536,8 +545,11 @@ class NoteEditorFragment :
formatListener =
TextFormatListener { formatter: Toolbar.TextFormatter ->
val currentFocus = requireActivity().currentFocus as? FieldEditText ?: return@TextFormatListener
saveCurrentTextState(currentFocus.id, currentFocus.text.toString())
modifyCurrentSelection(formatter, currentFocus)
redoStacks[currentFocus.id]?.clear()
}
actionsListener = this@NoteEditorFragment
// Sets the background and icon color of toolbar respectively.
setBackgroundColor(
MaterialColors.getColor(
Expand Down Expand Up @@ -1015,6 +1027,12 @@ class NoteEditorFragment :
formatter: Toolbar.TextFormatter,
textBox: FieldEditText,
) {
val fieldId = textBox.id
val currentTextBeforeModification = textBox.text.toString()
isPerformingUndoRedo = true
saveCurrentTextState(fieldId, currentTextBeforeModification)
redoStacks[fieldId]?.clear()

// get the current text and selection locations
val selectionStart = textBox.selectionStart
val selectionEnd = textBox.selectionEnd
Expand All @@ -1033,9 +1051,14 @@ class NoteEditorFragment :
// Update text field with updated text and selection
val length = beforeText.length + newText.length + afterText.length
val newFieldContent =
StringBuilder(length).append(beforeText).append(newText).append(afterText)
textBox.setText(newFieldContent)
textBox.setSelection(start + newStart, start + newEnd)
StringBuilder(length)
.append(beforeText)
.append(newText) // O resultado FORMATADO da seleção
.append(afterText)

textBox.setText(newFieldContent) // DEFINE O TEXTO MODIFICADO UMA ÚNICA VEZ
textBox.setSelection(start + newStart, start + newEnd) // DEFINE A SELEÇÃO CORRETA UMA ÚNICA VEZ
isPerformingUndoRedo = false
}

override fun onStop() {
Expand Down Expand Up @@ -1707,6 +1730,8 @@ class NoteEditorFragment :
showDialogFragment(dialog)
}

private fun getEditTextForFieldId(fieldIndex: Int): FieldEditText = editFields!![fieldIndex]

override fun onSelectedTags(
selectedTags: List<String>,
indeterminateTags: List<String>,
Expand All @@ -1719,6 +1744,39 @@ class NoteEditorFragment :
updateTags()
}

override fun performUndo() {
currentActiveFieldIn?.let { fieldId ->
val editText = getEditTextForFieldId(fieldId)
val undoStack = undoStacks.getOrPut(fieldId) { Stack() }
val redoStack = redoStacks.getOrPut(fieldId) { Stack() }

if (undoStack.isNotEmpty()) {
val currentText = editText.text.toString()
redoStack.push(currentText)

isPerformingUndoRedo = true
val previousText = undoStack.pop()
editText.setText(previousText)
editText.setSelection(previousText.length)
isPerformingUndoRedo = false
}
}
}

override fun saveCurrentTextState(
fieldId: Int,
text: String,
) {
val undoStack = undoStacks.getOrPut(fieldId) { Stack() }

if (undoStack.isNotEmpty() && undoStack.peek() == text) {
return
}

undoStack.push(text)
redoStacks[fieldId]?.clear()
}

private fun showCardTemplateEditor() {
val intent = Intent(requireContext(), CardTemplateEditor::class.java)
// Pass the note type ID
Expand Down Expand Up @@ -1863,6 +1921,22 @@ class NoteEditorFragment :
newEditText.textSize = prefs.getInt(PREF_NOTE_EDITOR_FONT_SIZE, -1).toFloat()
}
newEditText.setCapitalize(prefs.getBoolean(PREF_NOTE_EDITOR_CAPITALIZE, true))

newEditText.onFocusChangeListener =
View.OnFocusChangeListener { v, hasFocus ->
if (hasFocus) {
val focusedEditText = v as FieldEditText
val indexInEditFields = editFields?.indexOf(focusedEditText)
if (indexInEditFields != null && indexInEditFields != -1) {
currentActiveFieldIn = indexInEditFields
Timber.d(
"DEBUG_UNDO: Campo com ID ${v.id} (index $indexInEditFields) ganhou foco. currentActiveFieldIn definido para $currentActiveFieldIn",
)
} else {
Timber.e("DEBUG_UNDO: FieldEditText com ID ${v.id} não encontrado em editFields!")
}
}
}
val mediaButton = editLineView.mediaButton
val toggleStickyButton = editLineView.toggleSticky
// Make the icon change between media icon and switch field icon depending on whether editing note type
Expand All @@ -1875,7 +1949,6 @@ class NoteEditorFragment :
mediaButton.setBackgroundResource(0)
toggleStickyButton.setBackgroundResource(0)
} else {
// Use media editor button if not changing note type
mediaButton.setBackgroundResource(R.drawable.ic_attachment)

mediaButton.setOnClickListener {
Expand All @@ -1884,7 +1957,6 @@ class NoteEditorFragment :
}

if (addNote) {
// toggle sticky button
toggleStickyButton.setBackgroundResource(R.drawable.ic_baseline_push_pin_24)
setToggleStickyButtonListener(toggleStickyButton, i)
} else {
Expand Down Expand Up @@ -2240,7 +2312,7 @@ class NoteEditorFragment :
enabled: Boolean,
) {
// Listen for changes in the first field so we can re-check duplicate status.
editText!!.addTextChangedListener(EditFieldTextWatcher(index))
editText!!.addTextChangedListener(EditFieldTextWatcher(index, this))
if (index == 0) {
editText.onFocusChangeListener =
OnFocusChangeListener { _: View?, hasFocus: Boolean ->
Expand Down Expand Up @@ -2401,6 +2473,10 @@ class NoteEditorFragment :
if (selectedTags == null) {
selectedTags = editorNote!!.tags
}

undoStacks.clear()
redoStacks.clear()

// nb: setOnItemSelectedListener and populateEditFields need to occur after this
setNoteTypePosition()
setDid(note)
Expand Down Expand Up @@ -2907,7 +2983,6 @@ class NoteEditorFragment :
) {
val editText = editFields!![i]
editText.setText(newText)
EditFieldTextWatcher(i).afterTextChanged(editText.text!!)
}

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
Expand All @@ -2926,33 +3001,63 @@ class NoteEditorFragment :
private var loadingStickyFields = false

private inner class EditFieldTextWatcher(
private val index: Int,
private val fieldId: Int,
private val listener: NoteEditorActionsListener,
) : TextWatcher {
override fun afterTextChanged(arg0: Editable) {
if (!loadingStickyFields) {
isFieldEdited = true
}
if (index == 0) {
setDuplicateFieldStyles()
}
}
private var lastRecordedText: String = ""
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
private var runnable: Runnable? = null

override fun beforeTextChanged(
arg0: CharSequence,
arg1: Int,
arg2: Int,
arg3: Int,
s: CharSequence,
start: Int,
count: Int,
after: Int,
) {
// do nothing
// Capture text before change
if (isPerformingUndoRedo) return
lastRecordedText = s.toString()
}

override fun onTextChanged(
arg0: CharSequence,
arg1: Int,
arg2: Int,
arg3: Int,
s: CharSequence,
start: Int,
before: Int,
count: Int,
) {
// do nothing
if (isPerformingUndoRedo) return
runnable?.let { handler.removeCallbacks(it) }
}

override fun afterTextChanged(s: Editable) {
if (!loadingStickyFields) {
isFieldEdited = true
}
if (fieldId == 0) {
setDuplicateFieldStyles()
}

if (isPerformingUndoRedo) return
val textAfterEdit = s.toString()

if (lastRecordedText != textAfterEdit) {
val textToSaveOnUndo = lastRecordedText

runnable =
Runnable {
val undoStack = undoStacks.getOrPut(fieldId) { Stack() }

if (undoStack.isEmpty() || undoStack.peek() != textToSaveOnUndo) {
listener.saveCurrentTextState(fieldId, textToSaveOnUndo)
Timber.d("DEBUG_UNDO: CONSOLIDATED SAVE for fieldId: $fieldId, text: '$textToSaveOnUndo'")
} else {
Timber.d(
"DEBUG_UNDO: SKIPPING CONSOLIDATED SAVE for fieldId: $fieldId, text: '$textToSaveOnUndo' (duplicate or already at top)",
)
}
}
handler.postDelayed(runnable!!, 700)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Guilherme Silva <sgsouza.173@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.noteeditor

interface NoteEditorActionsListener {
fun performUndo()

fun saveCurrentTextState(
fieldId: Int,
text: String,
)
}
9 changes: 9 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import kotlin.math.ceil
*/
class Toolbar : FrameLayout {
var formatListener: TextFormatListener? = null
var actionsListener: NoteEditorActionsListener? = null
private val toolbar: LinearLayout
private val toolbarLayout: LinearLayout

Expand Down Expand Up @@ -130,6 +131,7 @@ class Toolbar : FrameLayout {
setupButtonWrappingText(R.id.note_editor_toolbar_button_underline, "<u>", "</u>")
setupButtonWrappingText(R.id.note_editor_toolbar_button_insert_mathjax, "\\(", "\\)")
setupButtonWrappingText(R.id.note_editor_toolbar_button_horizontal_rule, "<hr>", "")
findViewById<View>(R.id.note_editor_toolbar_button_undo).setOnClickListener { undoText() }
findViewById<View>(R.id.note_editor_toolbar_button_font_size).setOnClickListener { displayFontSizeDialog() }
findViewById<View>(R.id.note_editor_toolbar_button_title).setOnClickListener { displayInsertHeadingDialog() }
findViewById<View>(R.id.note_editor_toolbar_button_insert_mathjax).setOnLongClickListener {
Expand Down Expand Up @@ -343,6 +345,13 @@ class Toolbar : FrameLayout {
}
}

/**
* Initiates the undo action by notifying the TextFormatListener
*/
private fun undoText() {
actionsListener?.performUndo()
}

/** Given a string [text], generates a [Drawable] which can be used as a button icon */
fun createDrawableForString(text: String): Drawable {
val baseline = -stringPaint!!.ascent()
Expand Down
9 changes: 9 additions & 0 deletions AnkiDroid/src/main/res/drawable/ic_undo_black_24dp.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M280,760v-80h284q63,0 109.5,-40T720,540q0,-60 -46.5,-100T564,400L312,400l104,104 -56,56 -200,-200 200,-200 56,56 -104,104h252q97,0 166.5,63T800,540q0,94 -69.5,157T564,760L280,760Z"
android:fillColor="#000000"/>
</vector>
8 changes: 8 additions & 0 deletions AnkiDroid/src/main/res/layout/note_editor_toolbar.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@
android:tag="u"
style="@style/note_editor_toolbar_button" />

<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/note_editor_toolbar_button_undo"
app:srcCompat="@drawable/ic_undo_black_24dp"
android:contentDescription="@string/undo_text_change"
android:tooltipText="@string/undo_text_change"
android:tag="g"
style="@style/note_editor_toolbar_button" />

<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/note_editor_toolbar_button_horizontal_rule"
app:srcCompat="@drawable/ic_horizontal_rule_black_24dp"
Expand Down
1 change: 1 addition & 0 deletions AnkiDroid/src/main/res/values/02-strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
<string name="format_insert_bold">Format as Bold</string>
<string name="format_insert_italic">Format as Italic</string>
<string name="format_insert_underline">Format as Underline</string>
<string name="undo_text_change">Undo Last Change In Text</string>
<string name="insert_horizontal_line">Insert Horizontal Line</string>
<string name="insert_heading">Insert Heading</string>
<string name="format_font_size">Change Font Size</string>
Expand Down