Skip to content

Commit dffac9a

Browse files
committed
Fix list indentation and add test coverage
1 parent c6654ca commit dffac9a

File tree

3 files changed

+387
-24
lines changed

3 files changed

+387
-24
lines changed

aztec/src/main/kotlin/org/wordpress/aztec/AztecText.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,11 +948,15 @@ open class AztecText : AppCompatEditText, TextWatcher, UnknownHtmlSpan.OnUnknown
948948
}
949949

950950
fun indent() {
951+
history.beforeTextChanged(this@AztecText)
951952
blockFormatter.indent()
953+
contentChangeWatcher.notifyContentChanged()
952954
}
953955

954956
fun outdent() {
957+
history.beforeTextChanged(this@AztecText)
955958
blockFormatter.outdent()
959+
contentChangeWatcher.notifyContentChanged()
956960
}
957961

958962
fun setOnSelectionChangedListener(onSelectionChangedListener: OnSelectionChangedListener) {

aztec/src/main/kotlin/org/wordpress/aztec/formatting/ListFormatter.kt

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,31 @@ class ListFormatter(editor: AztecText) : AztecFormatter(editor) {
3434
// If the following list item is missing or it's on the same level as the current list item, create a new span
3535
if ((listItemSpanAfterSelection == null || listItemSpanAfterSelection.nestingLevel <= nestingLevel)) {
3636
selectedListItems.indentAll()
37-
val wrapper = directParent.copyList(increaseNestingLevel = true) ?: return@apply
38-
editableText.setSpan(wrapper, firstSelectedItemStart, lastSelectedItemEnd, directParentFlags)
37+
val wrapper = directParentList.copyList(increaseNestingLevel = true) ?: return@apply
38+
editableText.setSpan(wrapper, firstSelectedItemStart, selectionEnd, directParentFlags)
39+
3940
} else if (listSpanAfterSelection != null && listSpanAfterSelection.nestingLevel > nestingLevel) {
4041
selectedListItems.indentAll()
4142
listSpanAfterSelection.changeSpanStart(firstSelectedItemStart)
43+
selectedListItems.last().trimEnd(editableText.getSpanStart(listItemSpanAfterSelection))
4244
}
4345
} else if (deeperListSpanBeforeSelection?.nestingLevel == nestingLevel + 1) {
4446
// In this case the previous list span is indented by one level, we can indent current span on the same level
4547
if ((listItemSpanAfterSelection == null || listItemSpanAfterSelection.nestingLevel <= nestingLevel)) {
4648
selectedListItems.indentAll()
47-
deeperListSpanBeforeSelection.changeSpanEnd(lastSelectedItemEnd)
49+
deeperListSpanBeforeSelection.changeSpanEnd(selectionEnd)
4850
} else if (listItemSpanAfterSelection.nestingLevel == nextItemLevel) {
4951
// Merge previous and following list before and after the selection
5052
selectedListItems.indentAll()
5153
val followingSpanEnd = editableText.getSpanEnd(listSpanAfterSelection)
5254
editableText.removeSpan(listSpanAfterSelection)
5355
deeperListSpanBeforeSelection.changeSpanEnd(followingSpanEnd)
56+
selectedListItems.last().trimEnd(editableText.getSpanStart(listItemSpanAfterSelection))
5457
}
5558
}
59+
listItemSpansBeforeSelection.filter { it.nestingLevel < nextItemLevel }.forEach {
60+
it.stretchEnd(selectionEnd)
61+
}
5662
}
5763
return true
5864
}
@@ -82,64 +88,87 @@ class ListFormatter(editor: AztecText) : AztecFormatter(editor) {
8288
when {
8389
listItemSpanBeforeSelection == null && listItemSpanAfterSelection == null -> {
8490
// In case of the selected list spam doesn't have any predecessor or successor, remove the list container
85-
editableText.removeSpan(directParent)
91+
editableText.removeSpan(directParentList)
8692
selectedListItems.forEach { editableText.removeSpan(it) }
8793
}
8894
listItemSpanBeforeSelection == null && listItemSpanAfterSelection != null -> {
8995
if (listItemSpanAfterSelection.nestingLevel == nestingLevel) {
9096
// In case there is no predecessor and the successor has the same nesting level, move the list wrapper
9197
// to the end of the current selection and remove the selection from the list
9298
selectedListItems.outdentAll()
93-
directParent.changeSpanStart(lastSelectedItemEnd)
99+
directParentList.changeSpanStart(selectionEnd)
94100
}
95101
}
96102
listItemSpanBeforeSelection != null && listItemSpanAfterSelection == null -> {
97103
if (listItemSpanBeforeSelection.nestingLevel >= nestingLevel) {
98104
// In case there is no successor and the predecessor has the same nesting level, move the list wrapper
99105
// to the start of the current selection and remove the selection from the list
100106
selectedListItems.outdentAll()
101-
directParent.changeSpanEnd(firstSelectedItemStart)
107+
directParentList.changeSpanEnd(firstSelectedItemStart)
102108
} else {
103109
// Predecessor has a lower nesting level and there is no successor, this means that the currently
104110
// selected items can be all moved to lower nesting level and their wrapper can be removed
105111
selectedListItems.outdentAll()
106-
editableText.removeSpan(directParent)
112+
editableText.removeSpan(directParentList)
107113
}
114+
directParentListItem?.trimEnd(firstSelectedItemStart)
108115
}
109116
listItemSpanBeforeSelection != null && listItemSpanAfterSelection != null -> {
110117
if (listItemSpanBeforeSelection.nestingLevel >= nestingLevel) {
111118
if (listItemSpanAfterSelection.nestingLevel == nestingLevel) {
112119
// Predecessor and successor are on the same level as selected items, this means we have to split
113120
// the current list wrapper in half and move the selected items to the lower nesting level
114121
selectedListItems.outdentAll()
115-
val spanStart = editableText.getSpanStart(directParent)
116-
val spanFlags = editableText.getSpanFlags(directParent)
117-
editableText.setSpan(directParent.copyList(), spanStart, firstSelectedItemStart, spanFlags)
118-
directParent.changeSpanStart(lastSelectedItemEnd)
122+
val spanStart = editableText.getSpanStart(directParentList)
123+
val spanFlags = editableText.getSpanFlags(directParentList)
124+
editableText.setSpan(directParentList.copyList(), spanStart, firstSelectedItemStart, spanFlags)
125+
directParentListItem?.apply {
126+
val listItemEnd = editableText.getSpanEnd(this)
127+
selectedListItems.last().changeSpanEnd(listItemEnd)
128+
}
129+
directParentList.changeSpanStart(selectionEnd)
130+
directParentListItem?.changeSpanEnd(firstSelectedItemStart)
119131
} else if (listItemSpanAfterSelection.nestingLevel < nestingLevel) {
120132
// In case the span after selection has lower nesting level, we don't have to worry about it
121133
selectedListItems.outdentAll()
122-
directParent.changeSpanEnd(firstSelectedItemStart)
134+
directParentList.changeSpanEnd(firstSelectedItemStart)
135+
directParentListItem?.changeSpanEnd(firstSelectedItemStart)
123136
}
124137
} else if (listItemSpanBeforeSelection.nestingLevel < nestingLevel && listItemSpanAfterSelection.nestingLevel == nestingLevel) {
125138
// Predecessor is on lower level and successor is on the same level, this means we can move all the
126139
// selected items to lower level and leave the successor on the current level
127140
selectedListItems.outdentAll()
128-
directParent.changeSpanStart(lastSelectedItemEnd)
141+
directParentList.changeSpanStart(selectionEnd)
142+
directParentListItem?.changeSpanEnd(firstSelectedItemStart)
143+
selectedListItems.last().changeSpanEnd(editableText.getSpanEnd(directParentList))
129144
} else if (listItemSpanBeforeSelection.nestingLevel < nestingLevel && listItemSpanAfterSelection.nestingLevel < nestingLevel) {
130145
// In this case the selected items are the only items on the current level. Both the successor and
131146
// the predecessor are on a lower level. This means we can remove the wrapping span and move all
132147
// the selected items to the lower level.
133148
selectedListItems.outdentAll()
134-
editableText.removeSpan(directParent)
149+
editableText.removeSpan(directParentList)
150+
directParentListItem?.changeSpanEnd(firstSelectedItemStart)
135151
}
136152
}
137153
}
138154
}
139155
return true
140156
}
141157

142-
private data class ListState(val nestingLevel: Int, val directParent: AztecListSpan, val directParentFlags: Int, val selectedListItems: List<AztecListItemSpan>, val deeperListSpanBeforeSelection: AztecListSpan?, val listSpanAfterSelection: AztecListSpan?, val listItemSpanBeforeSelection: AztecListItemSpan?, val listItemSpanAfterSelection: AztecListItemSpan?, val firstSelectedItemStart: Int, val lastSelectedItemEnd: Int)
158+
private data class ListState(
159+
val nestingLevel: Int,
160+
val directParentList: AztecListSpan,
161+
val directParentListItem: AztecListItemSpan?,
162+
val directParentFlags: Int,
163+
val selectedListItems: List<AztecListItemSpan>,
164+
val deeperListSpanBeforeSelection: AztecListSpan?,
165+
val listSpanAfterSelection: AztecListSpan?,
166+
val listItemSpanBeforeSelection: AztecListItemSpan?,
167+
val listItemSpansBeforeSelection: List<AztecListItemSpan>,
168+
val listItemSpanAfterSelection: AztecListItemSpan?,
169+
val firstSelectedItemStart: Int,
170+
val selectionEnd: Int
171+
)
143172

144173
private fun buildListState(listSpans: List<AztecListSpan>, selStart: Int, selEnd: Int): ListState? {
145174
val directParent = listSpans.maxByOrNull { it.nestingLevel } ?: return null
@@ -148,42 +177,57 @@ class ListFormatter(editor: AztecText) : AztecFormatter(editor) {
148177
val fullListEnd = editableText.getSpanEnd(topLevelParent)
149178
val directParentFlags = editableText.getSpanFlags(directParent)
150179

180+
var startIndex = selStart
151181
val selectedItems = editableText.getSpans(selStart, selEnd, AztecListItemSpan::class.java).filterCorrectSpans(selectionStart = selStart, selectionEnd = selEnd)
152-
if (!validateSelection(selectedItems, directParent)) return null
153182
val selectedListItems = selectedItems.filter {
154183
it.nestingLevel > directParent.nestingLevel
155184
}
185+
var countdown = selectedListItems.size
186+
while (countdown > 0) {
187+
selectedListItems.find { startIndex in editableText.getSpanStart(it) until editableText.getSpanEnd(it) }?.let {
188+
startIndex = editableText.getSpanEnd(it)
189+
}
190+
countdown -= 1
191+
}
192+
if (startIndex < selEnd) return null
193+
194+
val directParentListItem = selectedItems.filter { it.nestingLevel < directParent.nestingLevel }.maxByOrNull { it.nestingLevel }
156195
if (selectedListItems.isEmpty()) return null
157196
val nestingLevel = selectedListItems.first().nestingLevel
158197
if (selectedListItems.any { it.nestingLevel != nestingLevel }) return null
159198
val firstSelectedItemStart = editableText.getSpanStart(selectedListItems.first())
160-
val lastSelectedItemEnd = editableText.getSpanEnd(selectedListItems.last())
199+
val selectionEnd = editableText.getSpanEnd(selectedListItems.last())
161200

162201
val allLists = editableText.getSpans(fullListStart, fullListEnd, AztecListSpan::class.java)
163202
val allListItems = editableText.getSpans(fullListStart, fullListEnd, AztecListItemSpan::class.java)
164203
val deeperListSpanBeforeSelection: AztecListSpan? = allLists.find {
165204
it.nestingLevel == nestingLevel + 1 && editableText.getSpanEnd(it) == firstSelectedItemStart
166205
}
167206
val listSpanAfterSelection: AztecListSpan? = allLists.find {
168-
editableText.getSpanStart(it) == lastSelectedItemEnd
207+
val spanStart = editableText.getSpanStart(it)
208+
val spanEnd = editableText.getSpanEnd(it)
209+
spanStart in (firstSelectedItemStart + 1)..selectionEnd && spanEnd >= selectionEnd
169210
}
170-
val listItemSpanBeforeSelection: AztecListItemSpan? = allListItems.find {
171-
editableText.getSpanEnd(it) == firstSelectedItemStart
211+
val listItemSpansBeforeSelection: List<AztecListItemSpan> = allListItems.filter {
212+
editableText.getSpanStart(it) < firstSelectedItemStart && editableText.getSpanEnd(it) >= firstSelectedItemStart
172213
}
173214
val listItemSpanAfterSelection: AztecListItemSpan? = allListItems.find {
174-
editableText.getSpanStart(it) == lastSelectedItemEnd
215+
val spanStart = editableText.getSpanStart(it)
216+
spanStart in (firstSelectedItemStart + 1)..selectionEnd
175217
}
176218
return ListState(
177219
nestingLevel = nestingLevel,
178-
directParent = directParent,
220+
directParentList = directParent,
221+
directParentListItem = directParentListItem,
179222
directParentFlags = directParentFlags,
180223
selectedListItems = selectedListItems,
181224
deeperListSpanBeforeSelection = deeperListSpanBeforeSelection,
182225
listSpanAfterSelection = listSpanAfterSelection,
183-
listItemSpanBeforeSelection = listItemSpanBeforeSelection,
226+
listItemSpanBeforeSelection = listItemSpansBeforeSelection.maxByOrNull { it.nestingLevel },
227+
listItemSpansBeforeSelection = listItemSpansBeforeSelection,
184228
listItemSpanAfterSelection = listItemSpanAfterSelection,
185229
firstSelectedItemStart = firstSelectedItemStart,
186-
lastSelectedItemEnd = lastSelectedItemEnd
230+
selectionEnd = selectionEnd
187231
)
188232
}
189233

@@ -239,5 +283,27 @@ class ListFormatter(editor: AztecText) : AztecFormatter(editor) {
239283
it.nestingLevel = nestingLevel + 2
240284
}
241285
}
286+
287+
private fun IAztecBlockSpan.stretchEnd(newEnd: Int) {
288+
val end = editableText.getSpanEnd(this)
289+
if (end >= newEnd) {
290+
return
291+
}
292+
val start = editableText.getSpanStart(this)
293+
val flags = editableText.getSpanFlags(this)
294+
editableText.removeSpan(this)
295+
editableText.setSpan(this, start, newEnd, flags)
296+
}
297+
298+
private fun IAztecBlockSpan.trimEnd(newEnd: Int) {
299+
val end = editableText.getSpanEnd(this)
300+
if (end <= newEnd) {
301+
return
302+
}
303+
val start = editableText.getSpanStart(this)
304+
val flags = editableText.getSpanFlags(this)
305+
editableText.removeSpan(this)
306+
editableText.setSpan(this, start, newEnd, flags)
307+
}
242308
}
243309

0 commit comments

Comments
 (0)