diff --git a/.maestro/enrichedInput/flows/dynamic_ol_markers.yaml b/.maestro/enrichedInput/flows/dynamic_ol_markers.yaml
new file mode 100644
index 000000000..6c7eb5b00
--- /dev/null
+++ b/.maestro/enrichedInput/flows/dynamic_ol_markers.yaml
@@ -0,0 +1,56 @@
+appId: swmansion.enriched.example
+---
+# Validates that ordered list margins dynamically adjust when transitioning
+# between single-digit (9) and double-digit (10) indexes.
+- launchApp
+
+- tapOn:
+ id: 'toggle-screen-button'
+
+- runFlow:
+ file: '../subflows/set_editor_value.yaml'
+ env:
+ VALUE: >
+
+
+ - A
+ - B
+ - C
+ - D
+ - E
+ - F
+ - G
+ - H
+ - I
+
+
+
+- tapOn:
+ id: 'size-max-button'
+
+- runFlow:
+ file: '../subflows/capture_or_assert_screenshot.yaml'
+ env:
+ SCREENSHOT_NAME: 'dynamic_ol_markers_before'
+
+- tapOn:
+ id: "editor-input"
+ point: "50%, 95%"
+- pressKey: Enter
+- inputText: "J"
+
+- runFlow:
+ file: '../subflows/capture_or_assert_screenshot.yaml'
+ env:
+ SCREENSHOT_NAME: 'dynamic_ol_markers_after'
+
+- tapOn:
+ id: "editor-input"
+ point: "50%, 95%"
+- pressKey: Backspace
+- pressKey: Backspace
+
+- runFlow:
+ file: '../subflows/capture_or_assert_screenshot.yaml'
+ env:
+ SCREENSHOT_NAME: 'dynamic_ol_markers_revert'
diff --git a/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png b/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png
index b3b661a85..2ebc3fd4b 100644
Binary files a/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png and b/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_after.png b/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_after.png
new file mode 100644
index 000000000..64f598cec
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_after.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_before.png b/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_before.png
new file mode 100644
index 000000000..f7e4937d1
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_before.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_revert.png b/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_revert.png
new file mode 100644
index 000000000..f567d83b6
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/dynamic_ol_markers_revert.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/empty_element_parsing.png b/.maestro/enrichedInput/screenshots/android/empty_element_parsing.png
index 7e28ad4ea..a6fd95fb1 100644
Binary files a/.maestro/enrichedInput/screenshots/android/empty_element_parsing.png and b/.maestro/enrichedInput/screenshots/android/empty_element_parsing.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/empty_lists_parsing.png b/.maestro/enrichedInput/screenshots/android/empty_lists_parsing.png
index d777b0b24..4af0f5167 100644
Binary files a/.maestro/enrichedInput/screenshots/android/empty_lists_parsing.png and b/.maestro/enrichedInput/screenshots/android/empty_lists_parsing.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_copy.png b/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_copy.png
index f717ac4fa..1bea7002c 100644
Binary files a/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_copy.png and b/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_copy.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_cut.png b/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_cut.png
index f717ac4fa..1bea7002c 100644
Binary files a/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_cut.png and b/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_cut.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/font_scaling.png b/.maestro/enrichedInput/screenshots/android/font_scaling.png
index 6b955aa4e..22495890f 100644
Binary files a/.maestro/enrichedInput/screenshots/android/font_scaling.png and b/.maestro/enrichedInput/screenshots/android/font_scaling.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/html_link_not_extended.png b/.maestro/enrichedInput/screenshots/android/html_link_not_extended.png
index d0198a17d..929c98c7a 100644
Binary files a/.maestro/enrichedInput/screenshots/android/html_link_not_extended.png and b/.maestro/enrichedInput/screenshots/android/html_link_not_extended.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/image_inside_list_parsing.png b/.maestro/enrichedInput/screenshots/android/image_inside_list_parsing.png
index 8c7af1ca3..b6055e22f 100644
Binary files a/.maestro/enrichedInput/screenshots/android/image_inside_list_parsing.png and b/.maestro/enrichedInput/screenshots/android/image_inside_list_parsing.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/inline_styles.png b/.maestro/enrichedInput/screenshots/android/inline_styles.png
index 818443706..c7255dc7d 100644
Binary files a/.maestro/enrichedInput/screenshots/android/inline_styles.png and b/.maestro/enrichedInput/screenshots/android/inline_styles.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/inline_styles_merge.png b/.maestro/enrichedInput/screenshots/android/inline_styles_merge.png
index e1b1cece0..8fa74ed41 100644
Binary files a/.maestro/enrichedInput/screenshots/android/inline_styles_merge.png and b/.maestro/enrichedInput/screenshots/android/inline_styles_merge.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/inline_styles_removal.png b/.maestro/enrichedInput/screenshots/android/inline_styles_removal.png
index 731ac2b2a..c9e51d0c7 100644
Binary files a/.maestro/enrichedInput/screenshots/android/inline_styles_removal.png and b/.maestro/enrichedInput/screenshots/android/inline_styles_removal.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/link_not_extended.png b/.maestro/enrichedInput/screenshots/android/link_not_extended.png
index 8fba4d554..63ac35167 100644
Binary files a/.maestro/enrichedInput/screenshots/android/link_not_extended.png and b/.maestro/enrichedInput/screenshots/android/link_not_extended.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/links_visual_auto.png b/.maestro/enrichedInput/screenshots/android/links_visual_auto.png
index 369f3a2ea..5c4778e6c 100644
Binary files a/.maestro/enrichedInput/screenshots/android/links_visual_auto.png and b/.maestro/enrichedInput/screenshots/android/links_visual_auto.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/links_visual_manual.png b/.maestro/enrichedInput/screenshots/android/links_visual_manual.png
index cc265a9db..65f205f9a 100644
Binary files a/.maestro/enrichedInput/screenshots/android/links_visual_manual.png and b/.maestro/enrichedInput/screenshots/android/links_visual_manual.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/list_newline_insertion.png b/.maestro/enrichedInput/screenshots/android/list_newline_insertion.png
index 12b3b0384..644dc7cce 100644
Binary files a/.maestro/enrichedInput/screenshots/android/list_newline_insertion.png and b/.maestro/enrichedInput/screenshots/android/list_newline_insertion.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png
index 74947bcfd..8fa6291ad 100644
Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png
index 7a053e6c5..c092fb52d 100644
Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png
index d80b9a756..cb34c00b1 100644
Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_deleting_second_line.png b/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_deleting_second_line.png
index 5d00fb1ce..a9bd4b766 100644
Binary files a/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_deleting_second_line.png and b/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_deleting_second_line.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_emptying_second_line.png b/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_emptying_second_line.png
index 53049a519..1b4605ea4 100644
Binary files a/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_emptying_second_line.png and b/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_emptying_second_line.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_alignment.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_alignment.png
index 31828ca25..7ef5ac4d0 100644
Binary files a/.maestro/enrichedInput/screenshots/android/paragraph_styles_alignment.png and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_alignment.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png
index 4d075df95..09ed9ee13 100644
Binary files a/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_headings.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_headings.png
index d94481f0a..cd0e16d10 100644
Binary files a/.maestro/enrichedInput/screenshots/android/paragraph_styles_headings.png and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_headings.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png
index 7bb6e38db..d6716f616 100644
Binary files a/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png
index 3278d7428..91d8dbbc8 100644
Binary files a/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png
index 9c2dd9795..fe9bf8db8 100644
Binary files a/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png and b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png differ
diff --git a/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png
index 3b946339f..298171e07 100644
Binary files a/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png and b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_after.png b/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_after.png
new file mode 100644
index 000000000..65dcb826d
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_after.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_before.png b/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_before.png
new file mode 100644
index 000000000..d76caeb35
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_before.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_revert.png b/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_revert.png
new file mode 100644
index 000000000..c3461cd4c
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/dynamic_ol_markers_revert.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/empty_element_parsing.png b/.maestro/enrichedInput/screenshots/ios/empty_element_parsing.png
index ea9dd3310..0a72ed95a 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/empty_element_parsing.png and b/.maestro/enrichedInput/screenshots/ios/empty_element_parsing.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/empty_lists_parsing.png b/.maestro/enrichedInput/screenshots/ios/empty_lists_parsing.png
index 015e7d397..0fc4306d6 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/empty_lists_parsing.png and b/.maestro/enrichedInput/screenshots/ios/empty_lists_parsing.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/image_inside_list_parsing.png b/.maestro/enrichedInput/screenshots/ios/image_inside_list_parsing.png
index 599bd6522..5fbddd064 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/image_inside_list_parsing.png and b/.maestro/enrichedInput/screenshots/ios/image_inside_list_parsing.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/list_newline_insertion.png b/.maestro/enrichedInput/screenshots/ios/list_newline_insertion.png
index ea1bdc616..8d10b6ccf 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/list_newline_insertion.png and b/.maestro/enrichedInput/screenshots/ios/list_newline_insertion.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_deleting_second_line.png b/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_deleting_second_line.png
index d72a13061..b9e6f15a6 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_deleting_second_line.png and b/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_deleting_second_line.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_emptying_second_line.png b/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_emptying_second_line.png
index 431d95210..8116c7122 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_emptying_second_line.png and b/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_emptying_second_line.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/paragraph_styles_alignment.png b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_alignment.png
index 28f837a59..63b13dd07 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/paragraph_styles_alignment.png and b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_alignment.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/paragraph_styles_lists.png b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_lists.png
index 2bee088d8..d0b9a2ed7 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/paragraph_styles_lists.png and b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_lists.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/paragraph_styles_no_crash.png b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_no_crash.png
index c54bd5738..538c2def3 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/paragraph_styles_no_crash.png and b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_no_crash.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png
index 96087f395..7b52e6e43 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png and b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png
index 241461ace..2fc6a1463 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png and b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png differ
diff --git a/.maestro/enrichedText/flows/dynamic_ol_markers.yaml b/.maestro/enrichedText/flows/dynamic_ol_markers.yaml
new file mode 100644
index 000000000..b8bd5d6e7
--- /dev/null
+++ b/.maestro/enrichedText/flows/dynamic_ol_markers.yaml
@@ -0,0 +1,41 @@
+appId: swmansion.enriched.example
+---
+# Validates that ordered list margins dynamically adjust when transitioning
+# between single-digit (9) and double-digit (10) indexes.
+- launchApp
+
+- tapOn:
+ id: 'toggle-screen-button'
+
+- tapOn:
+ id: 'toggle-enriched-text-screen-button'
+
+- runFlow:
+ file: '../subflows/set_enriched_text_value.yaml'
+ env:
+ VALUE: >
+
+
+ - A
+ - B
+ - C
+
+
+
+ - A
+ - B
+ - C
+ - D
+ - E
+ - F
+ - G
+ - H
+ - I
+ - J
+
+
+
+- runFlow:
+ file: '../subflows/capture_or_assert_screenshot.yaml'
+ env:
+ SCREENSHOT_NAME: 'dynamic_ol_markers'
diff --git a/.maestro/enrichedText/screenshots/android/alignment_visual.png b/.maestro/enrichedText/screenshots/android/alignment_visual.png
index c3e03756a..7c75ce27e 100644
Binary files a/.maestro/enrichedText/screenshots/android/alignment_visual.png and b/.maestro/enrichedText/screenshots/android/alignment_visual.png differ
diff --git a/.maestro/enrichedText/screenshots/android/dynamic_ol_markers.png b/.maestro/enrichedText/screenshots/android/dynamic_ol_markers.png
new file mode 100644
index 000000000..d572bec99
Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/dynamic_ol_markers.png differ
diff --git a/.maestro/enrichedText/screenshots/android/empty_list_elements_display.png b/.maestro/enrichedText/screenshots/android/empty_list_elements_display.png
index 62859c08e..cc42cad1d 100644
Binary files a/.maestro/enrichedText/screenshots/android/empty_list_elements_display.png and b/.maestro/enrichedText/screenshots/android/empty_list_elements_display.png differ
diff --git a/.maestro/enrichedText/screenshots/android/font_scaling.png b/.maestro/enrichedText/screenshots/android/font_scaling.png
index 488791012..4382f8163 100644
Binary files a/.maestro/enrichedText/screenshots/android/font_scaling.png and b/.maestro/enrichedText/screenshots/android/font_scaling.png differ
diff --git a/.maestro/enrichedText/screenshots/android/inline_styles_display.png b/.maestro/enrichedText/screenshots/android/inline_styles_display.png
index fe658211f..806725a3f 100644
Binary files a/.maestro/enrichedText/screenshots/android/inline_styles_display.png and b/.maestro/enrichedText/screenshots/android/inline_styles_display.png differ
diff --git a/.maestro/enrichedText/screenshots/android/paragraph_styles_display.png b/.maestro/enrichedText/screenshots/android/paragraph_styles_display.png
index 112148d77..8a9dce562 100644
Binary files a/.maestro/enrichedText/screenshots/android/paragraph_styles_display.png and b/.maestro/enrichedText/screenshots/android/paragraph_styles_display.png differ
diff --git a/.maestro/enrichedText/screenshots/ios/alignment_visual.png b/.maestro/enrichedText/screenshots/ios/alignment_visual.png
index cffcfac4b..b25f5d91e 100644
Binary files a/.maestro/enrichedText/screenshots/ios/alignment_visual.png and b/.maestro/enrichedText/screenshots/ios/alignment_visual.png differ
diff --git a/.maestro/enrichedText/screenshots/ios/dynamic_ol_markers.png b/.maestro/enrichedText/screenshots/ios/dynamic_ol_markers.png
new file mode 100644
index 000000000..1e5763cb4
Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/dynamic_ol_markers.png differ
diff --git a/.maestro/enrichedText/screenshots/ios/empty_list_elements_display.png b/.maestro/enrichedText/screenshots/ios/empty_list_elements_display.png
index 03dcbd827..b90b8b7ea 100644
Binary files a/.maestro/enrichedText/screenshots/ios/empty_list_elements_display.png and b/.maestro/enrichedText/screenshots/ios/empty_list_elements_display.png differ
diff --git a/.maestro/enrichedText/screenshots/ios/font_scaling.png b/.maestro/enrichedText/screenshots/ios/font_scaling.png
index 95d5c5808..3c7139eda 100644
Binary files a/.maestro/enrichedText/screenshots/ios/font_scaling.png and b/.maestro/enrichedText/screenshots/ios/font_scaling.png differ
diff --git a/.maestro/enrichedText/screenshots/ios/paragraph_styles_display.png b/.maestro/enrichedText/screenshots/ios/paragraph_styles_display.png
index 8abd1b2af..a09e6d38c 100644
Binary files a/.maestro/enrichedText/screenshots/ios/paragraph_styles_display.png and b/.maestro/enrichedText/screenshots/ios/paragraph_styles_display.png differ
diff --git a/android/src/main/java/com/swmansion/enriched/common/EmptyParagraphSpan.kt b/android/src/main/java/com/swmansion/enriched/common/EmptyParagraphSpan.kt
new file mode 100644
index 000000000..64bc018f4
--- /dev/null
+++ b/android/src/main/java/com/swmansion/enriched/common/EmptyParagraphSpan.kt
@@ -0,0 +1,7 @@
+package com.swmansion.enriched.common
+
+import android.text.style.ParagraphStyle
+
+// A no-op ParagraphStyle. Setting one over a range forces
+// Android to re-layout it.
+class EmptyParagraphSpan : ParagraphStyle
diff --git a/android/src/main/java/com/swmansion/enriched/common/OrderedListMargins.kt b/android/src/main/java/com/swmansion/enriched/common/OrderedListMargins.kt
new file mode 100644
index 000000000..e71dcad8d
--- /dev/null
+++ b/android/src/main/java/com/swmansion/enriched/common/OrderedListMargins.kt
@@ -0,0 +1,70 @@
+package com.swmansion.enriched.common
+
+import android.graphics.Paint
+import android.text.Spannable
+import com.swmansion.enriched.common.spans.EnrichedOrderedListSpan
+import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries
+
+// Recomputes the shared marker column width for every ordered-list item.
+fun updateOrderedListColumnMargins(
+ text: Spannable,
+ paint: Paint,
+) {
+ val spans = text.getSpans(0, text.length, EnrichedOrderedListSpan::class.java)
+ val sortedSpans = spans.sortedBy { text.getSpanStart(it) }
+
+ val changedLists = mutableSetOf>()
+ var currentList = mutableListOf()
+ var currentListChanged = false
+ var previousIndex = 0
+ var highestIndex = 0
+
+ for (span in sortedSpans.reversed()) {
+ if (span.index >= previousIndex) {
+ if (currentListChanged) {
+ // we'll re-layout that list
+ changedLists.add(currentList)
+ }
+
+ // we entered a new distinct list
+ currentList = mutableListOf()
+ currentListChanged = false
+ highestIndex = span.index
+ }
+
+ currentList.add(span)
+ if (span.updateColumnMargin(paint, highestIndex)) {
+ currentListChanged = true
+ }
+
+ previousIndex = span.index
+ }
+
+ if (currentListChanged) {
+ changedLists.add(currentList)
+ }
+
+ // A single empty ParagraphStyle over the whole list forces one re-layout of it.
+ // Uses the same trick as EnrichedSpanWatcher.updateNextLineLayout.
+ for (list in changedLists) {
+ forceOrderedListRelayout(text, list)
+ }
+}
+
+private fun forceOrderedListRelayout(
+ text: Spannable,
+ listSpans: List,
+) {
+ if (listSpans.isEmpty()) return
+
+ // Because the list was populated during a reversed() loop, the elements
+ // are in descending order. last() is the start of the list, first() is the end.
+ val start = text.getSpanStart(listSpans.last())
+ val end = text.getSpanEnd(listSpans.first())
+
+ if (start < 0 || end < 0 || start > end) return
+
+ val (safeStart, safeEnd) = text.getSafeSpanBoundaries(start, end)
+ text.getSpans(safeStart, safeEnd, EmptyParagraphSpan::class.java).forEach { text.removeSpan(it) }
+ text.setSpan(EmptyParagraphSpan(), safeStart, safeEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+}
diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedOrderedListSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedOrderedListSpan.kt
index d57619c91..474cb3f52 100644
--- a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedOrderedListSpan.kt
+++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedOrderedListSpan.kt
@@ -10,6 +10,8 @@ import android.text.style.LeadingMarginSpan
import android.text.style.MetricAffectingSpan
import com.swmansion.enriched.common.EnrichedStyle
import com.swmansion.enriched.common.spans.interfaces.EnrichedParagraphSpan
+import kotlin.math.ceil
+import kotlin.math.max
open class EnrichedOrderedListSpan(
var index: Int,
@@ -17,6 +19,23 @@ open class EnrichedOrderedListSpan(
) : MetricAffectingSpan(),
LeadingMarginSpan,
EnrichedParagraphSpan {
+ var columnMargin = enrichedStyle.olMarginLeft
+
+ // Computes the marker column width from the widest marker in the list (".").
+ // Returns true when the column width actually changed, so callers can force a relayout.
+ fun updateColumnMargin(
+ paint: Paint,
+ highestIndex: Int,
+ ): Boolean {
+ val (digitWidth, dotWidth) = markerMetricsFor(paint, paint.typeface)
+ val highestIndexWidth = ceil(digitCountOf(highestIndex) * digitWidth + dotWidth).toInt()
+
+ val newColumnMargin = max(enrichedStyle.olMarginLeft, highestIndexWidth)
+ if (newColumnMargin == columnMargin) return false
+ columnMargin = newColumnMargin
+ return true
+ }
+
override fun updateMeasureState(p0: TextPaint) {
// Do nothing, but inform layout that this span affects text metrics
}
@@ -25,7 +44,7 @@ open class EnrichedOrderedListSpan(
// Do nothing, but inform layout that this span affects text metrics
}
- override fun getLeadingMargin(first: Boolean): Int = enrichedStyle.olMarginLeft + enrichedStyle.olGapWidth
+ override fun getLeadingMargin(first: Boolean): Int = columnMargin + enrichedStyle.olGapWidth
override fun drawLeadingMargin(
canvas: Canvas,
@@ -42,17 +61,17 @@ open class EnrichedOrderedListSpan(
layout: Layout?,
) {
if (first) {
+ val originalColor = paint.color
+ val originalTypeface = paint.typeface
+ paint.color = enrichedStyle.olMarkerColor ?: originalColor
+ paint.typeface = getTypeface(enrichedStyle.olMarkerFontWeight, originalTypeface)
+
val text = "$index."
val width = paint.measureText(text)
val yPosition = baseline.toFloat()
- val xPosition = (enrichedStyle.olMarginLeft + x - width / 2) * dir
+ val xPosition = (columnMargin + x - width) * dir
- val originalColor = paint.color
- val originalTypeface = paint.typeface
-
- paint.color = enrichedStyle.olMarkerColor ?: originalColor
- paint.typeface = getTypeface(enrichedStyle.olMarkerFontWeight, originalTypeface)
canvas.drawText(text, xPosition, yPosition, paint)
paint.color = originalColor
@@ -76,4 +95,39 @@ open class EnrichedOrderedListSpan(
Typeface.create(originalTypeface, Typeface.NORMAL)
}
}
+
+ private fun markerMetricsFor(
+ paint: Paint,
+ baseTypeface: Typeface,
+ ): Pair {
+ val key = MarkerMetricsKey(baseTypeface, enrichedStyle.olMarkerFontWeight, paint.textSize)
+ return markerMetricsCache.getOrPut(key) {
+ val originalTypeface = paint.typeface
+ paint.typeface = getTypeface(enrichedStyle.olMarkerFontWeight, baseTypeface)
+ val digitWidth = paint.measureText("0")
+ val dotWidth = paint.measureText(".")
+ paint.typeface = originalTypeface
+ Pair(digitWidth, dotWidth)
+ }
+ }
+
+ private data class MarkerMetricsKey(
+ val baseTypeface: Typeface,
+ val fontWeight: Int?,
+ val textSize: Float,
+ )
+
+ companion object {
+ private val markerMetricsCache = mutableMapOf>()
+
+ private fun digitCountOf(n: Int): Int {
+ var count = 1
+ var value = max(n, 1)
+ while (value >= 10) {
+ value /= 10
+ count++
+ }
+ return count
+ }
+ }
}
diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt
index d1ee9b4ff..c79890bee 100644
--- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt
+++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt
@@ -98,7 +98,7 @@ data class EnrichedTextStyle(
blockquoteStripeWidth = parseFloat(blockquote, "borderWidth", allowFontScaling).toInt(),
blockquoteGapWidth = parseFloat(blockquote, "gapWidth", allowFontScaling).toInt(),
olGapWidth = parseFloat(orderedList, "gapWidth", allowFontScaling).toInt(),
- olMarginLeft = calculateOlMarginLeft(fontSize, parseFloat(orderedList, "marginLeft", allowFontScaling).toInt()),
+ olMarginLeft = parseFloat(orderedList, "marginLeft", allowFontScaling).toInt(),
olMarkerFontWeight = parseOptionalFontWeight(orderedList, "markerFontWeight"),
olMarkerColor = parseOptionalColor(context, orderedList, "markerColor"),
ulGapWidth = parseFloat(unorderedList, "gapWidth", allowFontScaling).toInt(),
@@ -169,14 +169,6 @@ data class EnrichedTextStyle(
return parseFontWeight(weight)
}
- private fun calculateOlMarginLeft(
- fontSize: Int,
- userMargin: Int,
- ): Int {
- val leadMargin = fontSize / 2
- return leadMargin + userMargin
- }
-
private fun parseMentionsStyle(
context: ReactContext,
map: ReadableMap?,
diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt
index 357016da3..b642258e3 100644
--- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt
+++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt
@@ -27,6 +27,7 @@ import com.swmansion.enriched.common.EnrichedSpanFlags
import com.swmansion.enriched.common.GumboNormalizer
import com.swmansion.enriched.common.parser.EnrichedParser
import com.swmansion.enriched.common.pixelFromSpOrDp
+import com.swmansion.enriched.common.updateOrderedListColumnMargins
import com.swmansion.enriched.text.spans.EnrichedTextImageSpan
import com.swmansion.enriched.text.spans.interfaces.EnrichedTextClickableSpan
import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan
@@ -182,6 +183,7 @@ class EnrichedTextView : AppCompatTextView {
val parsed = parseText(text, style)
if (parsed != null) {
parsedText = parsed
+ (parsed as? Spannable)?.let { updateOrderedListColumnMargins(it, paint) }
setText(parsed, BufferType.NORMAL)
observeAsyncImages()
} else {
@@ -287,6 +289,7 @@ class EnrichedTextView : AppCompatTextView {
}
if (modified) {
+ updateOrderedListColumnMargins(spannable, paint)
this.text = spannable
}
}
diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt
index f4aed71ea..235b17ff0 100644
--- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt
+++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt
@@ -43,6 +43,7 @@ import com.swmansion.enriched.common.EnrichedSpanFlags
import com.swmansion.enriched.common.GumboNormalizer
import com.swmansion.enriched.common.parser.EnrichedParser
import com.swmansion.enriched.common.pixelFromSpOrDp
+import com.swmansion.enriched.common.updateOrderedListColumnMargins
import com.swmansion.enriched.textinput.events.MentionHandler
import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent
import com.swmansion.enriched.textinput.events.OnInputBlurEvent
@@ -448,6 +449,9 @@ class EnrichedTextInputView :
runAsATransaction {
val newText = if (shouldParseHtml) parseText(value) else value
+ // Compute ordered-list marker column widths before setText so the first layout reserves
+ // the correct leading margin (getLeadingMargin runs during layout, before drawing).
+ (newText as? Spannable)?.let { updateOrderedListColumnMargins(it, paint) }
setText(newText)
applyLineSpacing()
diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt
index 5a772ded9..a02e8398f 100644
--- a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt
+++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt
@@ -113,9 +113,7 @@ class HtmlStyle : EnrichedStyle {
blockquoteStripeWidth = parseFloat(blockquoteStyle, "borderWidth").toInt()
val olStyle = style.getMap("ol")
- val userDefinedMarginLeft = parseFloat(olStyle, "marginLeft").toInt()
- val calculatedMarginLeft = calculateOlMarginLeft(view, userDefinedMarginLeft)
- olMarginLeft = calculatedMarginLeft
+ olMarginLeft = parseFloat(olStyle, "marginLeft").toInt()
olGapWidth = parseFloat(olStyle, "gapWidth").toInt()
olMarkerColor = parseOptionalColor(olStyle, "markerColor")
olMarkerFontWeight = parseOptionalFontWeight(olStyle, "markerFontWeight")
@@ -213,16 +211,6 @@ class HtmlStyle : EnrichedStyle {
throw Error("Specified textDecorationLine value is not supported: $underline. Supported values are 'underline' and 'none'.")
}
- private fun calculateOlMarginLeft(
- view: EnrichedTextInputView?,
- userMargin: Int,
- ): Int {
- val fontSize = view?.fontSize?.toInt() ?: 0
- val leadMargin = fontSize / 2
-
- return leadMargin + userMargin
- }
-
private fun ensureValueIsSet(
map: ReadableMap?,
key: String,
diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt
index 236560f10..3ab7c410b 100644
--- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt
+++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt
@@ -6,6 +6,7 @@ import android.text.SpannableStringBuilder
import android.text.Spanned
import com.swmansion.enriched.common.EnrichedConstants
import com.swmansion.enriched.common.EnrichedSpanFlags
+import com.swmansion.enriched.common.updateOrderedListColumnMargins
import com.swmansion.enriched.textinput.EnrichedTextInputView
import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan
import com.swmansion.enriched.textinput.spans.EnrichedInputOrderedListSpan
@@ -115,6 +116,8 @@ class ListStyles(
val index = getOrderedListIndex(text, spanStart)
span.setListIndex(index)
}
+
+ updateOrderedListColumnMargins(text, view.paint)
}
private fun toggleStyle(
diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedSpanWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedSpanWatcher.kt
index b3d7b0936..f0aa812d3 100644
--- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedSpanWatcher.kt
+++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedSpanWatcher.kt
@@ -3,9 +3,9 @@ package com.swmansion.enriched.textinput.watchers
import android.text.SpanWatcher
import android.text.Spannable
import android.text.style.MetricAffectingSpan
-import android.text.style.ParagraphStyle
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
+import com.swmansion.enriched.common.EmptyParagraphSpan
import com.swmansion.enriched.common.parser.EnrichedParser
import com.swmansion.enriched.common.spans.interfaces.EnrichedHeadingSpan
import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan
@@ -70,13 +70,11 @@ class EnrichedSpanWatcher(
text: Spannable,
end: Int,
) {
- class EmptySpan : ParagraphStyle
-
if (what is EnrichedHeadingSpan || (what is EnrichedInlineSpan && what is MetricAffectingSpan)) {
val finalStart = (end + 1)
val finalEnd = text.length
val (safeStart, safeEnd) = text.getSafeSpanBoundaries(finalStart, finalEnd)
- text.setSpan(EmptySpan(), safeStart, safeEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ text.setSpan(EmptyParagraphSpan(), safeStart, safeEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts
index 2b9d44b0b..63043835e 100644
--- a/apps/example/src/constants/editorConfig.ts
+++ b/apps/example/src/constants/editorConfig.ts
@@ -111,7 +111,7 @@ export const htmlStyle = {
},
ol: {
gapWidth: 16,
- marginLeft: 24,
+ marginLeft: 16,
markerColor: 'navy',
markerFontWeight: 'bold',
},
diff --git a/docs/docs/api-reference/enriched-text-input.md b/docs/docs/api-reference/enriched-text-input.md
index a600904f5..457e967b3 100644
--- a/docs/docs/api-reference/enriched-text-input.md
+++ b/docs/docs/api-reference/enriched-text-input.md
@@ -1242,6 +1242,14 @@ By marker, we mean the number that denotes consecutive lines of the list.
[color](https://reactnative.dev/docs/colors) value; if not set, defaults to
the input's [color](#style)
+:::note
+
+As you may have noticed, there is no way to define an ordered list marker’s width (unlike other list styles).
+This is because the marker value changes, so its width is dynamic.
+The marker is rendered _inside_ the space defined by `marginLeft` (right-aligned within it), so keep this in mind when styling your UI.
+
+:::
+
### ul (unordered list)
By bullet, we mean the dot that begins each line of the list.
diff --git a/ios/extensions/LayoutManagerExtension.mm b/ios/extensions/LayoutManagerExtension.mm
index 72c9620eb..38d033b05 100644
--- a/ios/extensions/LayoutManagerExtension.mm
+++ b/ios/extensions/LayoutManagerExtension.mm
@@ -448,7 +448,7 @@ - (void)drawDecimal:(id)host
indent:(CGFloat)indent {
CGFloat gapWidth = [host.config orderedListGapWidth];
CGSize markerSize = [marker sizeWithAttributes:markerAttributes];
- CGFloat markerX = origin.x + indent - gapWidth - markerSize.width / 2;
+ CGFloat markerX = origin.x + indent - gapWidth - markerSize.width;
UIFont *markerFont = markerAttributes[NSFontAttributeName];
// drawAtPoint draws from the top-left bounding box of the string.
diff --git a/ios/inputAttributesManager/InputAttributesManager.mm b/ios/inputAttributesManager/InputAttributesManager.mm
index cd768339a..8c9b76fdf 100644
--- a/ios/inputAttributesManager/InputAttributesManager.mm
+++ b/ios/inputAttributesManager/InputAttributesManager.mm
@@ -66,16 +66,27 @@ - (void)clearRemovedTypingAttributes {
}
- (void)handleDirtyRangesStyling {
- // Filter out 0 length ranges for styling.
- NSPredicate *predicate = [NSPredicate
- predicateWithBlock:^BOOL(NSValue *evaluatedObject, NSDictionary *_) {
- return [evaluatedObject rangeValue].length > 0;
- }];
- [_dirtyRanges filterUsingPredicate:predicate];
+ OrderedListStyle *orderedListStyle =
+ (OrderedListStyle *)_input->stylesDict[@([OrderedListStyle getType])];
for (NSValue *rangeObj in _dirtyRanges) {
NSRange dirtyRange = [rangeObj rangeValue];
+ // deletion (0-length dirtyRange) means we need to refresh the ordered
+ // list margins. If the ordered list style is not present itself,
+ // it might mean that we have just deleted a list element and need
+ // to recalculate the potentially split list (current adjacent lists)
+ if (dirtyRange.length == 0) {
+ if (orderedListStyle != nil) {
+ if ([orderedListStyle detect:dirtyRange]) {
+ [orderedListStyle applyStyling:dirtyRange];
+ } else {
+ [orderedListStyle recalculateListsAroundEditedRange:dirtyRange];
+ }
+ }
+ continue;
+ }
+
// dirty range can sometimes be wrong because of apple doing some changes
// behind the scenes
if (dirtyRange.location + dirtyRange.length >
@@ -90,6 +101,13 @@ - (void)handleDirtyRangesStyling {
presentStyles[@([[style class] getType])] = [style all:dirtyRange];
}
+ // it's possible that ordered list style has just got removed,
+ // so we have to refresh adjacent ordered lists
+ if (orderedListStyle != nil &&
+ [presentStyles[@([OrderedListStyle getType])] count] == 0) {
+ [orderedListStyle recalculateListsAroundEditedRange:dirtyRange];
+ }
+
// now reset the attributes to default ones
[_input->textView.textStorage setAttributes:_input->defaultTypingAttributes
range:dirtyRange];
@@ -122,6 +140,7 @@ - (void)handleDirtyRangesStyling {
}
}
}
+
// do the typing attributes management, with no selection
[self manageTypingAttributesWithOnlySelection:NO];
diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h
index aca5d4a3c..d59b9c424 100644
--- a/ios/interfaces/StyleHeaders.h
+++ b/ios/interfaces/StyleHeaders.h
@@ -76,6 +76,7 @@
@end
@interface OrderedListStyle : StyleBase
+- (void)recalculateListsAroundEditedRange:(NSRange)range;
@end
@interface CheckboxListStyle : StyleBase
diff --git a/ios/styles/OrderedListStyle.mm b/ios/styles/OrderedListStyle.mm
index 717d0887c..82c5ad3b4 100644
--- a/ios/styles/OrderedListStyle.mm
+++ b/ios/styles/OrderedListStyle.mm
@@ -1,4 +1,5 @@
#import "EnrichedTextInputView.h"
+#import "OrderedListMetricsUtils.h"
#import "RangeUtils.h"
#import "StyleHeaders.h"
#import "StyleUtils.h"
@@ -22,24 +23,96 @@ - (BOOL)needsZWS {
return YES;
}
-- (CGFloat)headIndent {
- return [self.host.config orderedListMarginLeft] +
- [self.host.config orderedListGapWidth];
-}
-
- (void)applyStyling:(NSRange)range {
// lists are drawn manually
- // margin before marker + gap between marker and paragraph
- CGFloat listHeadIndent = [self headIndent];
+
+ // if the widest counter ("N.") width overflows the initially given margin,
+ // we expand that margin. Every item in the same contiguous list must share
+ // the same column width, so we expand to the full ordered list occurrence and
+ // re-indent all of it - even when only a single paragraph is dirty (e.g. an
+ // item was just added)
+ NSInteger itemCount = 0;
+ NSRange listRange =
+ [OrderedListMetricsUtils contiguousOrderedListRangeContaining:range
+ forStyle:self
+ itemCount:&itemCount];
+
+ [self applyIndentForListRange:listRange itemCount:itemCount];
+}
+
+// re-styling is normally run only on dirty-ranges, but a dirty-range
+// may change an ordered list structure and those lists need to be
+// re-styled. E.g. it happens when we remove an ordered list
+// element - it affects the adjacent lists, as their ordinals are different
+// and the computed margin might be stale
+- (void)recalculateListsAroundEditedRange:(NSRange)range {
+ NSUInteger length = self.host.textView.textStorage.string.length;
+ NSUInteger start = range.location;
+ NSUInteger end = NSMaxRange(range);
+
+ // look for ordered lists in adjacent locations
+ NSMutableArray *seeds = [NSMutableArray array];
+ if (start > 0) {
+ [seeds addObject:@(start - 1)];
+ }
+ [seeds addObject:@(start)];
+ [seeds addObject:@(end)];
+
+ // dedupe so each surviving contiguous list is recomputed at most once
+ NSMutableArray *handled = [NSMutableArray array];
+
+ for (NSNumber *seedNum in seeds) {
+ NSUInteger seed = seedNum.unsignedIntegerValue;
+ if (seed >= length) {
+ continue;
+ }
+ if (![self detect:NSMakeRange(seed, 0)]) {
+ continue;
+ }
+
+ BOOL alreadyHandled = NO;
+ for (NSValue *handledRange in handled) {
+ if (NSLocationInRange(seed, [handledRange rangeValue])) {
+ alreadyHandled = YES;
+ break;
+ }
+ }
+ if (alreadyHandled) {
+ continue;
+ }
+
+ NSInteger itemCount = 0;
+ NSRange listRange = [OrderedListMetricsUtils
+ contiguousOrderedListRangeContaining:NSMakeRange(seed, 0)
+ forStyle:self
+ itemCount:&itemCount];
+ [handled addObject:[NSValue valueWithRange:listRange]];
+ [self applyIndentForListRange:listRange itemCount:itemCount];
+ }
+}
+
+- (void)applyIndentForListRange:(NSRange)listRange
+ itemCount:(NSInteger)itemCount {
+ CGFloat listHeadIndent =
+ [OrderedListMetricsUtils headIndentForItemCount:itemCount
+ config:self.host.config];
[self.host.textView.textStorage
enumerateAttribute:NSParagraphStyleAttributeName
- inRange:range
+ inRange:listRange
options:0
usingBlock:^(id _Nullable value, NSRange range,
BOOL *_Nonnull stop) {
+ NSParagraphStyle *existing = (NSParagraphStyle *)value;
+ // skip re-styling paragraphs that don't require it
+ if (existing != nullptr &&
+ existing.headIndent == listHeadIndent &&
+ existing.firstLineHeadIndent == listHeadIndent) {
+ return;
+ }
NSMutableParagraphStyle *pStyle =
- [(NSParagraphStyle *)value mutableCopy];
+ existing ? [existing mutableCopy]
+ : [[NSMutableParagraphStyle alloc] init];
pStyle.headIndent = listHeadIndent;
pStyle.firstLineHeadIndent = listHeadIndent;
[self.host.textView.textStorage
@@ -58,9 +131,32 @@ - (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes {
[attributes[NSParagraphStyleAttributeName] mutableCopy];
if (pStyle == nil)
return;
- CGFloat indent = [self headIndent];
- pStyle.headIndent = indent;
- pStyle.firstLineHeadIndent = indent;
+
+ NSUInteger location = self.host.textView.selectedRange.location;
+ NSUInteger length = self.host.textView.textStorage.length;
+
+ NSParagraphStyle *existingStyle = nil;
+ if (length > 0) {
+ // applying styling to typing attributes always happen after applying
+ // the styles, so we can lookup the existing style for the indent
+ NSUInteger lookupLocation = MIN(location, length - 1);
+ existingStyle =
+ [self.host.textView.textStorage attribute:NSParagraphStyleAttributeName
+ atIndex:lookupLocation
+ effectiveRange:NULL];
+ }
+
+ if (existingStyle) {
+ pStyle.headIndent = existingStyle.headIndent;
+ pStyle.firstLineHeadIndent = existingStyle.firstLineHeadIndent;
+ } else {
+ CGFloat fallbackIndent =
+ [OrderedListMetricsUtils headIndentForItemCount:1
+ config:self.host.config];
+ pStyle.headIndent = fallbackIndent;
+ pStyle.firstLineHeadIndent = fallbackIndent;
+ }
+
attributes[NSParagraphStyleAttributeName] = pStyle;
}
diff --git a/ios/styles/UnorderedListStyle.mm b/ios/styles/UnorderedListStyle.mm
index 160ac946b..0e94894ac 100644
--- a/ios/styles/UnorderedListStyle.mm
+++ b/ios/styles/UnorderedListStyle.mm
@@ -24,6 +24,7 @@ - (BOOL)needsZWS {
- (CGFloat)headIndent {
return [self.host.config unorderedListMarginLeft] +
+ [self.host.config unorderedListBulletSize] +
[self.host.config unorderedListGapWidth];
}
diff --git a/ios/utils/OrderedListMetricsUtils.h b/ios/utils/OrderedListMetricsUtils.h
new file mode 100644
index 000000000..0c82976ec
--- /dev/null
+++ b/ios/utils/OrderedListMetricsUtils.h
@@ -0,0 +1,16 @@
+#pragma once
+#import "StyleHeaders.h"
+#import
+
+@interface OrderedListMetricsUtils : NSObject
+
++ (NSInteger)digitCountOf:(NSInteger)n;
+
++ (CGFloat)headIndentForItemCount:(NSInteger)itemCount
+ config:(EnrichedConfig *)config;
+
++ (NSRange)contiguousOrderedListRangeContaining:(NSRange)range
+ forStyle:(OrderedListStyle *)style
+ itemCount:(NSInteger *)outCount;
+
+@end
diff --git a/ios/utils/OrderedListMetricsUtils.mm b/ios/utils/OrderedListMetricsUtils.mm
new file mode 100644
index 000000000..bccd792f1
--- /dev/null
+++ b/ios/utils/OrderedListMetricsUtils.mm
@@ -0,0 +1,151 @@
+#import "OrderedListMetricsUtils.h"
+
+typedef struct {
+ CGFloat digitWidth;
+ CGFloat dotWidth;
+} OrderedListMarkerMetrics;
+
+static NSMutableDictionary *markerMetricsCache(void) {
+ static NSMutableDictionary *cache;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ cache = [NSMutableDictionary dictionary];
+ });
+ return cache;
+}
+
+static OrderedListMarkerMetrics markerMetricsForFont(UIFont *font) {
+ NSMutableDictionary *cache = markerMetricsCache();
+ NSValue *cached = cache[font];
+ OrderedListMarkerMetrics metrics;
+ if (cached != nil) {
+ [cached getValue:&metrics];
+ return metrics;
+ }
+
+ NSDictionary *attrs = @{NSFontAttributeName : font};
+ metrics.digitWidth = [@"0" sizeWithAttributes:attrs].width;
+ metrics.dotWidth = [@"." sizeWithAttributes:attrs].width;
+
+ cache[font] = [NSValue value:&metrics
+ withObjCType:@encode(OrderedListMarkerMetrics)];
+ return metrics;
+}
+
+static NSInteger digitCountOf(NSInteger n) {
+ NSInteger count = 1;
+ NSInteger value = MAX(n, 1);
+ while (value >= 10) {
+ value /= 10;
+ count += 1;
+ }
+ return count;
+}
+
+// counts paragraphs (newline-delimited) within a range that is already known
+// to start and end exactly on paragraph boundaries
+static NSInteger countParagraphsInRange(NSRange listRange, NSString *text) {
+ if (listRange.length == 0) {
+ return 0;
+ }
+
+ NSCharacterSet *newlineSet = [NSCharacterSet newlineCharacterSet];
+ NSUInteger rangeEnd = NSMaxRange(listRange);
+ NSUInteger cursor = listRange.location;
+ NSInteger count = 0;
+
+ while (cursor < rangeEnd) {
+ count += 1;
+ NSRange newline =
+ [text rangeOfCharacterFromSet:newlineSet
+ options:0
+ range:NSMakeRange(cursor, rangeEnd - cursor)];
+ cursor = newline.location != NSNotFound ? NSMaxRange(newline) : rangeEnd;
+ }
+
+ return count;
+}
+
+@implementation OrderedListMetricsUtils
+
++ (NSInteger)digitCountOf:(NSInteger)n {
+ return digitCountOf(n);
+}
+
++ (CGFloat)headIndentForItemCount:(NSInteger)itemCount
+ config:(EnrichedConfig *)config {
+ // we don't want to re-measure each marker's actual
+ // width. We estimate the width with cached metrics instead
+ OrderedListMarkerMetrics metrics =
+ markerMetricsForFont([config orderedListMarkerFont]);
+
+ NSInteger digitCount = digitCountOf(itemCount);
+ CGFloat widestMarkerWidth =
+ digitCount * metrics.digitWidth + metrics.dotWidth;
+
+ CGFloat markerColumnWidth =
+ MAX([config orderedListMarginLeft], widestMarkerWidth);
+ return markerColumnWidth + [config orderedListGapWidth];
+}
+
++ (NSRange)contiguousOrderedListRangeContaining:(NSRange)range
+ forStyle:(OrderedListStyle *)style
+ itemCount:(NSInteger *)outCount {
+ NSString *fullText = style.host.textView.textStorage.string;
+ NSUInteger length = fullText.length;
+ if (length == 0) {
+ if (outCount != nullptr) {
+ *outCount = 0;
+ }
+ return NSMakeRange(range.location, 0);
+ }
+
+ NSTextStorage *textStorage = style.host.textView.textStorage;
+ NSRange fullRange = NSMakeRange(0, length);
+ NSUInteger seedLocation = MIN(range.location, length - 1);
+
+ NSRange seedRun;
+ [textStorage attribute:NSParagraphStyleAttributeName
+ atIndex:seedLocation
+ longestEffectiveRange:&seedRun
+ inRange:fullRange];
+
+ NSUInteger firstParagraphStart = seedRun.location;
+ NSUInteger lastParagraphEnd = NSMaxRange(seedRun);
+
+ // seek backward over preceding ordered-list runs
+ while (firstParagraphStart > 0) {
+ if (![style detect:NSMakeRange(firstParagraphStart - 1, 0)]) {
+ break;
+ }
+ NSRange previousRun;
+ [textStorage attribute:NSParagraphStyleAttributeName
+ atIndex:firstParagraphStart - 1
+ longestEffectiveRange:&previousRun
+ inRange:fullRange];
+ firstParagraphStart = previousRun.location;
+ }
+
+ // seek forward over following ordered-list runs
+ while (lastParagraphEnd < length) {
+ if (![style detect:NSMakeRange(lastParagraphEnd, 0)]) {
+ break;
+ }
+ NSRange nextRun;
+ [textStorage attribute:NSParagraphStyleAttributeName
+ atIndex:lastParagraphEnd
+ longestEffectiveRange:&nextRun
+ inRange:fullRange];
+ lastParagraphEnd = NSMaxRange(nextRun);
+ }
+
+ NSRange listRange =
+ NSMakeRange(firstParagraphStart, lastParagraphEnd - firstParagraphStart);
+
+ if (outCount != nullptr) {
+ *outCount = countParagraphsInRange(listRange, fullText);
+ }
+ return listRange;
+}
+
+@end