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: > + +
    +
  1. A
  2. +
  3. B
  4. +
  5. C
  6. +
  7. D
  8. +
  9. E
  10. +
  11. F
  12. +
  13. G
  14. +
  15. H
  16. +
  17. I
  18. +
+ + +- 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: > + +
    +
  1. A
  2. +
  3. B
  4. +
  5. C
  6. +
+
+
    +
  1. A
  2. +
  3. B
  4. +
  5. C
  6. +
  7. D
  8. +
  9. E
  10. +
  11. F
  12. +
  13. G
  14. +
  15. H
  16. +
  17. I
  18. +
  19. J
  20. +
+ + +- 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