Skip to content

Commit b8b3c7b

Browse files
committed
Adding undo redo capabilities.
[Offscreen] Implementing undo and redo for scratch. [Offscreen] Adding undo/redo add and remove stroke items [Offscreen] Clearing stack on file loading. [Offscreen] Better colors for drawable. [Offscreen] Clearing listeners on activity sotp [Offscreen] Renaming UndoRedo naming to EditorHistory. [Offscreen] Remove coroutine launch from private methods.
1 parent 616b05a commit b8b3c7b

File tree

5 files changed

+231
-2
lines changed

5 files changed

+231
-2
lines changed

samples/offscreen-interactivity/src/main/java/com/myscript/iink/demo/inksample/ui/InkViewModel.kt

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ data class RecognitionFeedback(
5151
val words: List<Word> = emptyList()
5252
)
5353

54+
enum class EditorHistoryAction {
55+
ADD,
56+
REMOVE
57+
}
58+
59+
data class EditorHistoryItem(
60+
val editorHistoryAction: EditorHistoryAction,
61+
val strokes: List<InkView.Brush>
62+
)
63+
64+
data class EditorHistoryState(
65+
val canUndo: Boolean = false,
66+
val canRedo: Boolean = false
67+
)
68+
5469
/**
5570
* ViewModel responsible for maintaining the state of the OffScreenInteractivity demo application.
5671
*
@@ -72,6 +87,12 @@ class InkViewModel(
7287
val strokes: LiveData<List<InkView.Brush>>
7388
get() = _strokes
7489

90+
private val undoRedoStack = mutableListOf<List<EditorHistoryItem>>()
91+
private var undoRedoIndex = 0
92+
private val _editorHistoryState: MutableLiveData<EditorHistoryState> = MutableLiveData(EditorHistoryState())
93+
val editorHistoryState: LiveData<EditorHistoryState>
94+
get() = _editorHistoryState
95+
7596
// The iinkModel and recognitionFeedback are straightforward methods for debugging and showcasing, providing visual representation for easier understanding.
7697
// While this is not the method your app should use to display recognition, it can provide a starting point or guide on how to accomplish this.
7798
private val _recognitionFeedback: MutableLiveData<RecognitionFeedback> = MutableLiveData(RecognitionFeedback())
@@ -164,10 +185,24 @@ class InkViewModel(
164185
// or when tasks have side-effects that must be isolated to a single thread.
165186
withContext(workDispatcher) {
166187
// ItemIds may refer to partial strokes, retrieve the corresponding full strokes ids
167-
val fullStrokeIds = itemIds.map(itemIdHelper::getFullItemId) + gestureStrokeId
188+
val fullStrokeIds = itemIds.map(itemIdHelper::getFullItemId)
168189

169-
// Erase the gesture stroke (gestureStrokeId) and the erased strokes (fullItemIds) in your application
190+
val strokesToRemove = mutableListOf<InkView.Brush>()
170191
fullStrokeIds.forEach { strokeId ->
192+
val appStrokeId = strokeIdsMapping[strokeId]
193+
remainingStrokes.firstOrNull { it.id == appStrokeId }?.let { strokeBrush ->
194+
strokesToRemove.add(strokeBrush)
195+
}
196+
}
197+
198+
removeLastFromUndoRedoStack()
199+
val newHistory = addToUndoRedoStack(EditorHistoryAction.REMOVE, strokesToRemove)
200+
withContext(uiDispatcher) {
201+
_editorHistoryState.value = newHistory
202+
}
203+
204+
// Erase the gesture stroke (gestureStrokeId) and the erased strokes (fullItemIds) in your application
205+
(fullStrokeIds + gestureStrokeId).forEach { strokeId ->
171206
val appStrokeId = strokeIdsMapping[strokeId]
172207
strokeIdsMapping.remove(strokeId)
173208
val strokeBrush = remainingStrokes.firstOrNull { it.id == appStrokeId }
@@ -222,6 +257,19 @@ class InkViewModel(
222257
emptyArray()
223258
}
224259

260+
val strokesToRemove = mutableListOf<InkView.Brush>()
261+
fullItemIds.forEach { strokeId ->
262+
val appStrokeId = strokeIdsMapping[strokeId]
263+
remainingStrokes.firstOrNull { it.id == appStrokeId }?.let {
264+
strokesToRemove.add(it)
265+
}
266+
}
267+
removeLastFromUndoRedoStack()
268+
val newHistory = addToUndoRedoStack(EditorHistoryAction.REMOVE, strokesToRemove)
269+
withContext(uiDispatcher) {
270+
_editorHistoryState.value = newHistory
271+
}
272+
225273
// Erase the erased strokes and gesture strokes in your application
226274
(fullItemIds + gestureStrokeId).forEach { strokeId ->
227275
val appStrokeId = strokeIdsMapping[strokeId]
@@ -325,16 +373,145 @@ class InkViewModel(
325373
}
326374
}
327375

376+
private fun addToUndoRedoStack(action: EditorHistoryAction, strokes: List<InkView.Brush>): EditorHistoryState {
377+
return addToUndoRedoStack(listOf(EditorHistoryItem(action, strokes)))
378+
}
379+
380+
private fun addToUndoRedoStack(editorHistoryItems: List<EditorHistoryItem>): EditorHistoryState {
381+
synchronized(undoRedoStack) {
382+
if (undoRedoStack.isNotEmpty()) {
383+
for (i in (undoRedoStack.size - 1).downTo(undoRedoIndex)) {
384+
undoRedoStack.removeAt(i)
385+
}
386+
}
387+
undoRedoStack.add(undoRedoIndex++, editorHistoryItems)
388+
389+
return EditorHistoryState(
390+
canUndo = undoRedoIndex > 0,
391+
canRedo = undoRedoIndex < undoRedoStack.size
392+
)
393+
}
394+
}
395+
396+
private fun addStrokesForUndoRedo(initialStrokes: List<InkView.Brush>, strokesToAdd: List<InkView.Brush>): List<InkView.Brush> {
397+
val strokes = initialStrokes.toMutableList()
398+
strokes.addAll(strokesToAdd)
399+
400+
val pointerEvents = strokesToAdd.flatMap { brush ->
401+
brush.stroke.toPointerEvents().map { pointerEvent ->
402+
pointerEvent.convertPointerEvent(converter)
403+
}
404+
}.toTypedArray()
405+
406+
if (pointerEvents.isNotEmpty()) {
407+
val addedStrokes = offscreenEditor?.addStrokes(pointerEvents, false)
408+
409+
if (addedStrokes != null) {
410+
strokesToAdd.forEachIndexed { index, brush ->
411+
if (index in addedStrokes.indices) {
412+
strokeIdsMapping[addedStrokes[index]] = brush.id
413+
}
414+
}
415+
}
416+
}
417+
418+
return strokes
419+
}
420+
421+
private fun removeStrokesForUndoRedo(initialStrokes: List<InkView.Brush>, strokesToRemove: List<InkView.Brush>): List<InkView.Brush> {
422+
val updatedStrokes = initialStrokes.filter {
423+
it.id !in strokesToRemove.map { strokeToRemove -> strokeToRemove.id }
424+
}
425+
426+
val strokeToUndoMapping = strokeIdsMapping.filter { (_, appStrokeId) ->
427+
appStrokeId in strokesToRemove.map { strokeToRemove -> strokeToRemove.id }
428+
}
429+
offscreenEditor?.erase(strokeToUndoMapping.keys.toTypedArray())
430+
strokeToUndoMapping.forEach {
431+
strokeIdsMapping.remove(it.key)
432+
}
433+
434+
return updatedStrokes
435+
}
436+
437+
private fun clearUndoRedoStack(): EditorHistoryState {
438+
synchronized(undoRedoStack) {
439+
undoRedoStack.clear()
440+
undoRedoIndex = 0
441+
return EditorHistoryState()
442+
}
443+
}
444+
445+
private fun removeLastFromUndoRedoStack(): EditorHistoryState {
446+
synchronized(undoRedoStack) {
447+
undoRedoStack.removeAt(--undoRedoIndex)
448+
449+
return EditorHistoryState(
450+
canUndo = undoRedoIndex > 0,
451+
canRedo = undoRedoIndex < undoRedoStack.size
452+
)
453+
}
454+
}
455+
456+
fun undo() {
457+
viewModelScope.launch(uiDispatcher) {
458+
if (undoRedoIndex == 0 || undoRedoStack.isEmpty()) return@launch
459+
460+
val undoItems = synchronized(undoRedoStack){
461+
undoRedoStack[--undoRedoIndex]
462+
}
463+
undoItems.forEach { item ->
464+
val initialStrokes = strokes.value ?: emptyList()
465+
_strokes.value = when (item.editorHistoryAction) {
466+
EditorHistoryAction.ADD -> removeStrokesForUndoRedo(initialStrokes, item.strokes)
467+
EditorHistoryAction.REMOVE -> addStrokesForUndoRedo(initialStrokes, item.strokes)
468+
}
469+
}
470+
471+
_editorHistoryState.value = EditorHistoryState(
472+
canUndo = undoRedoIndex > 0,
473+
canRedo = undoRedoIndex < undoRedoStack.size
474+
)
475+
}
476+
}
477+
478+
fun redo() {
479+
viewModelScope.launch(uiDispatcher) {
480+
if (undoRedoIndex == undoRedoStack.size || undoRedoStack.isEmpty()) return@launch
481+
482+
val redoItems = synchronized(undoRedoStack) {
483+
undoRedoStack[undoRedoIndex++]
484+
}
485+
redoItems.forEach { item ->
486+
val initialStrokes = strokes.value ?: emptyList()
487+
_strokes.value = when (item.editorHistoryAction) {
488+
EditorHistoryAction.ADD -> addStrokesForUndoRedo(initialStrokes, item.strokes)
489+
EditorHistoryAction.REMOVE -> removeStrokesForUndoRedo(initialStrokes, item.strokes)
490+
}
491+
}
492+
493+
_editorHistoryState.value = EditorHistoryState(
494+
canUndo = undoRedoIndex > 0,
495+
canRedo = undoRedoIndex < undoRedoStack.size
496+
)
497+
}
498+
}
499+
328500
fun clearInk() {
329501
viewModelScope.launch(uiDispatcher) {
330502
offscreenEditor?.clear()
331503
strokeIdsMapping.clear()
504+
505+
_editorHistoryState.value = addToUndoRedoStack(EditorHistoryAction.REMOVE, _strokes.value ?: emptyList())
506+
332507
_strokes.value = emptyList()
333508
}
334509
}
335510

336511
fun loadInk() {
337512
viewModelScope.launch(uiDispatcher) {
513+
_editorHistoryState.value = clearUndoRedoStack()
514+
338515
val jsonString = withContext(ioDispatcher) {
339516
repository.readInkFromFile()
340517
}
@@ -402,6 +579,8 @@ class InkViewModel(
402579
offscreenEditor?.addStrokes(pointerEvents, true)?.firstNotNullOf { strokeId ->
403580
strokeIdsMapping[strokeId] = brush.id
404581
}
582+
583+
_editorHistoryState.value = addToUndoRedoStack(EditorHistoryAction.ADD, listOf(brush))
405584
}
406585
}
407586

samples/offscreen-interactivity/src/main/java/com/myscript/iink/demo/inksample/ui/MainActivity.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,16 @@ class MainActivity : AppCompatActivity() {
4646
}
4747
inkViewModel.recognitionFeedback.observe(this, ::onRecognitionUpdate)
4848
inkViewModel.iinkModel.observe(this, ::onIInkModelUpdate)
49+
inkViewModel.editorHistoryState.observe(this, ::onUndoRedoStateUpdate)
4950
}
5051

5152
override fun onStart() {
5253
super.onStart()
5354

5455
with(binding) {
5556
inkView.strokesListener = StrokesListener()
57+
undoBtn.setOnClickListener { inkViewModel.undo() }
58+
redoBtn.setOnClickListener { inkViewModel.redo() }
5659
clearInkBtn.setOnClickListener { inkViewModel.clearInk() }
5760
recognitionSwitch.setOnCheckedChangeListener { _, isChecked ->
5861
inkViewModel.toggleRecognition(isVisible = isChecked)
@@ -69,6 +72,8 @@ class MainActivity : AppCompatActivity() {
6972
}
7073
with(binding) {
7174
inkView.strokesListener = null
75+
undoBtn.setOnClickListener(null)
76+
redoBtn.setOnClickListener(null)
7277
clearInkBtn.setOnClickListener(null)
7378
recognitionSwitch.setOnCheckedChangeListener(null)
7479
}
@@ -100,6 +105,13 @@ class MainActivity : AppCompatActivity() {
100105
binding.iinkModelPreview.loadData(htmlExport, "text/html", Charsets.UTF_8.toString())
101106
}
102107

108+
private fun onUndoRedoStateUpdate(editorHistoryState: EditorHistoryState) {
109+
with(binding) {
110+
undoBtn.isEnabled = editorHistoryState.canUndo
111+
redoBtn.isEnabled = editorHistoryState.canRedo
112+
}
113+
}
114+
103115
/**
104116
* Listen for strokes from InkView.InputManager
105117
*/
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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+
android:tint="?attr/colorControlNormal">
7+
<path
8+
android:fillColor="#FF000000"
9+
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.22L3.9,16c1.05,-3.19 4.05,-5.5 7.6,-5.5 1.95,0 3.73,0.72 5.12,1.88L13,16h9V7l-3.6,3.6z" />
10+
</vector>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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+
android:tint="?attr/colorControlNormal">
7+
<path
8+
android:fillColor="#FF000000"
9+
android:pathData="M12.5 8c-2.65 0-5.05.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-.78C21.08 11.03 17.15 8 12.5 8Z"/>
10+
</vector>

samples/offscreen-interactivity/src/main/res/layout/main_activity.xml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@
2424
android:gravity="center_vertical"
2525
android:orientation="horizontal">
2626

27+
<ImageView
28+
android:id="@+id/undo_btn"
29+
android:layout_width="48dp"
30+
android:layout_height="48dp"
31+
android:padding="12dp"
32+
android:background="?attr/selectableItemBackgroundBorderless"
33+
android:src="@drawable/ic_undo"
34+
android:contentDescription="Undo" />
35+
36+
<ImageView
37+
android:id="@+id/redo_btn"
38+
android:layout_width="48dp"
39+
android:layout_height="48dp"
40+
android:padding="12dp"
41+
android:background="?attr/selectableItemBackgroundBorderless"
42+
android:src="@drawable/ic_redo"
43+
android:contentDescription="Redo" />
44+
2745
<ImageView
2846
android:id="@+id/clear_ink_btn"
2947
android:layout_width="48dp"

0 commit comments

Comments
 (0)