diff --git a/.maestro/flows/checkbox_toggle.yaml b/.maestro/enrichedInput/flows/checkbox_toggle.yaml similarity index 100% rename from .maestro/flows/checkbox_toggle.yaml rename to .maestro/enrichedInput/flows/checkbox_toggle.yaml diff --git a/.maestro/flows/codeblock_br_preservation.yaml b/.maestro/enrichedInput/flows/codeblock_br_preservation.yaml similarity index 100% rename from .maestro/flows/codeblock_br_preservation.yaml rename to .maestro/enrichedInput/flows/codeblock_br_preservation.yaml diff --git a/.maestro/flows/codeblock_no_link_detection.yaml b/.maestro/enrichedInput/flows/codeblock_no_link_detection.yaml similarity index 100% rename from .maestro/flows/codeblock_no_link_detection.yaml rename to .maestro/enrichedInput/flows/codeblock_no_link_detection.yaml diff --git a/.maestro/flows/codeblock_style_blocking.yaml b/.maestro/enrichedInput/flows/codeblock_style_blocking.yaml similarity index 100% rename from .maestro/flows/codeblock_style_blocking.yaml rename to .maestro/enrichedInput/flows/codeblock_style_blocking.yaml diff --git a/.maestro/flows/conflicting_paragraph_merge.yaml b/.maestro/enrichedInput/flows/conflicting_paragraph_merge.yaml similarity index 100% rename from .maestro/flows/conflicting_paragraph_merge.yaml rename to .maestro/enrichedInput/flows/conflicting_paragraph_merge.yaml diff --git a/.maestro/flows/core_controls_smoke.yaml b/.maestro/enrichedInput/flows/core_controls_smoke.yaml similarity index 100% rename from .maestro/flows/core_controls_smoke.yaml rename to .maestro/enrichedInput/flows/core_controls_smoke.yaml diff --git a/.maestro/flows/empty_element_parsing.yaml b/.maestro/enrichedInput/flows/empty_element_parsing.yaml similarity index 100% rename from .maestro/flows/empty_element_parsing.yaml rename to .maestro/enrichedInput/flows/empty_element_parsing.yaml diff --git a/.maestro/flows/empty_html_block_parsing.yaml b/.maestro/enrichedInput/flows/empty_html_block_parsing.yaml similarity index 100% rename from .maestro/flows/empty_html_block_parsing.yaml rename to .maestro/enrichedInput/flows/empty_html_block_parsing.yaml diff --git a/.maestro/flows/empty_lists_parsing.yaml b/.maestro/enrichedInput/flows/empty_lists_parsing.yaml similarity index 100% rename from .maestro/flows/empty_lists_parsing.yaml rename to .maestro/enrichedInput/flows/empty_lists_parsing.yaml diff --git a/.maestro/flows/extending_paragraph_style_on_paste_after_copy.yaml b/.maestro/enrichedInput/flows/extending_paragraph_style_on_paste_after_copy.yaml similarity index 100% rename from .maestro/flows/extending_paragraph_style_on_paste_after_copy.yaml rename to .maestro/enrichedInput/flows/extending_paragraph_style_on_paste_after_copy.yaml diff --git a/.maestro/flows/extending_paragraph_style_on_paste_after_cut.yaml b/.maestro/enrichedInput/flows/extending_paragraph_style_on_paste_after_cut.yaml similarity index 100% rename from .maestro/flows/extending_paragraph_style_on_paste_after_cut.yaml rename to .maestro/enrichedInput/flows/extending_paragraph_style_on_paste_after_cut.yaml diff --git a/.maestro/flows/html_link_not_extended.yaml b/.maestro/enrichedInput/flows/html_link_not_extended.yaml similarity index 100% rename from .maestro/flows/html_link_not_extended.yaml rename to .maestro/enrichedInput/flows/html_link_not_extended.yaml diff --git a/.maestro/flows/image_position_stability.yaml b/.maestro/enrichedInput/flows/image_position_stability.yaml similarity index 100% rename from .maestro/flows/image_position_stability.yaml rename to .maestro/enrichedInput/flows/image_position_stability.yaml diff --git a/.maestro/flows/initial_html_parsing.yaml b/.maestro/enrichedInput/flows/initial_html_parsing.yaml similarity index 100% rename from .maestro/flows/initial_html_parsing.yaml rename to .maestro/enrichedInput/flows/initial_html_parsing.yaml diff --git a/.maestro/flows/inline_styles_survive_block_toggle.yaml b/.maestro/enrichedInput/flows/inline_styles_survive_block_toggle.yaml similarity index 100% rename from .maestro/flows/inline_styles_survive_block_toggle.yaml rename to .maestro/enrichedInput/flows/inline_styles_survive_block_toggle.yaml diff --git a/.maestro/flows/inline_styles_visual.yaml b/.maestro/enrichedInput/flows/inline_styles_visual.yaml similarity index 100% rename from .maestro/flows/inline_styles_visual.yaml rename to .maestro/enrichedInput/flows/inline_styles_visual.yaml diff --git a/.maestro/flows/link_not_extended.yaml b/.maestro/enrichedInput/flows/link_not_extended.yaml similarity index 100% rename from .maestro/flows/link_not_extended.yaml rename to .maestro/enrichedInput/flows/link_not_extended.yaml diff --git a/.maestro/flows/list_newline_insertion.yaml b/.maestro/enrichedInput/flows/list_newline_insertion.yaml similarity index 100% rename from .maestro/flows/list_newline_insertion.yaml rename to .maestro/enrichedInput/flows/list_newline_insertion.yaml diff --git a/.maestro/flows/mention_parsing_handles_single_quoted_attributes.yaml b/.maestro/enrichedInput/flows/mention_parsing_handles_single_quoted_attributes.yaml similarity index 100% rename from .maestro/flows/mention_parsing_handles_single_quoted_attributes.yaml rename to .maestro/enrichedInput/flows/mention_parsing_handles_single_quoted_attributes.yaml diff --git a/.maestro/flows/ordered_list_renumbering.yaml b/.maestro/enrichedInput/flows/ordered_list_renumbering.yaml similarity index 100% rename from .maestro/flows/ordered_list_renumbering.yaml rename to .maestro/enrichedInput/flows/ordered_list_renumbering.yaml diff --git a/.maestro/flows/paragraph_style_removal.yaml b/.maestro/enrichedInput/flows/paragraph_style_removal.yaml similarity index 100% rename from .maestro/flows/paragraph_style_removal.yaml rename to .maestro/enrichedInput/flows/paragraph_style_removal.yaml diff --git a/.maestro/flows/paragraph_style_toggle.yaml b/.maestro/enrichedInput/flows/paragraph_style_toggle.yaml similarity index 100% rename from .maestro/flows/paragraph_style_toggle.yaml rename to .maestro/enrichedInput/flows/paragraph_style_toggle.yaml diff --git a/.maestro/flows/paragraph_styles_blocks_visual.yaml b/.maestro/enrichedInput/flows/paragraph_styles_blocks_visual.yaml similarity index 100% rename from .maestro/flows/paragraph_styles_blocks_visual.yaml rename to .maestro/enrichedInput/flows/paragraph_styles_blocks_visual.yaml diff --git a/.maestro/flows/paragraph_styles_headings_visual.yaml b/.maestro/enrichedInput/flows/paragraph_styles_headings_visual.yaml similarity index 100% rename from .maestro/flows/paragraph_styles_headings_visual.yaml rename to .maestro/enrichedInput/flows/paragraph_styles_headings_visual.yaml diff --git a/.maestro/flows/paragraph_styles_lists_visual.yaml b/.maestro/enrichedInput/flows/paragraph_styles_lists_visual.yaml similarity index 100% rename from .maestro/flows/paragraph_styles_lists_visual.yaml rename to .maestro/enrichedInput/flows/paragraph_styles_lists_visual.yaml diff --git a/.maestro/flows/paragraph_styles_no_crash.yaml b/.maestro/enrichedInput/flows/paragraph_styles_no_crash.yaml similarity index 100% rename from .maestro/flows/paragraph_styles_no_crash.yaml rename to .maestro/enrichedInput/flows/paragraph_styles_no_crash.yaml diff --git a/.maestro/flows/placeholder_visual.yaml b/.maestro/enrichedInput/flows/placeholder_visual.yaml similarity index 100% rename from .maestro/flows/placeholder_visual.yaml rename to .maestro/enrichedInput/flows/placeholder_visual.yaml diff --git a/.maestro/flows/scrolling_after_typing.yaml b/.maestro/enrichedInput/flows/scrolling_after_typing.yaml similarity index 100% rename from .maestro/flows/scrolling_after_typing.yaml rename to .maestro/enrichedInput/flows/scrolling_after_typing.yaml diff --git a/.maestro/flows/scrolling_set_value.yaml b/.maestro/enrichedInput/flows/scrolling_set_value.yaml similarity index 100% rename from .maestro/flows/scrolling_set_value.yaml rename to .maestro/enrichedInput/flows/scrolling_set_value.yaml diff --git a/.maestro/flows/scrolling_with_paragraph_styles.yaml b/.maestro/enrichedInput/flows/scrolling_with_paragraph_styles.yaml similarity index 100% rename from .maestro/flows/scrolling_with_paragraph_styles.yaml rename to .maestro/enrichedInput/flows/scrolling_with_paragraph_styles.yaml diff --git a/.maestro/screenshots/android/checkbox_toggle.png b/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png similarity index 100% rename from .maestro/screenshots/android/checkbox_toggle.png rename to .maestro/enrichedInput/screenshots/android/checkbox_toggle.png diff --git a/.maestro/screenshots/android/codeblock_no_link_detection.png b/.maestro/enrichedInput/screenshots/android/codeblock_no_link_detection.png similarity index 100% rename from .maestro/screenshots/android/codeblock_no_link_detection.png rename to .maestro/enrichedInput/screenshots/android/codeblock_no_link_detection.png diff --git a/.maestro/screenshots/android/codeblock_style_blocking.png b/.maestro/enrichedInput/screenshots/android/codeblock_style_blocking.png similarity index 100% rename from .maestro/screenshots/android/codeblock_style_blocking.png rename to .maestro/enrichedInput/screenshots/android/codeblock_style_blocking.png diff --git a/.maestro/screenshots/android/conflicting_paragraph_merge.png b/.maestro/enrichedInput/screenshots/android/conflicting_paragraph_merge.png similarity index 100% rename from .maestro/screenshots/android/conflicting_paragraph_merge.png rename to .maestro/enrichedInput/screenshots/android/conflicting_paragraph_merge.png diff --git a/.maestro/screenshots/android/empty_element_parsing.png b/.maestro/enrichedInput/screenshots/android/empty_element_parsing.png similarity index 100% rename from .maestro/screenshots/android/empty_element_parsing.png rename to .maestro/enrichedInput/screenshots/android/empty_element_parsing.png diff --git a/.maestro/screenshots/android/empty_html_block_parsing.png b/.maestro/enrichedInput/screenshots/android/empty_html_block_parsing.png similarity index 100% rename from .maestro/screenshots/android/empty_html_block_parsing.png rename to .maestro/enrichedInput/screenshots/android/empty_html_block_parsing.png diff --git a/.maestro/screenshots/android/empty_lists_parsing.png b/.maestro/enrichedInput/screenshots/android/empty_lists_parsing.png similarity index 100% rename from .maestro/screenshots/android/empty_lists_parsing.png rename to .maestro/enrichedInput/screenshots/android/empty_lists_parsing.png diff --git a/.maestro/screenshots/android/extending_paragraph_style_on_paste_after_copy.png b/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_copy.png similarity index 100% rename from .maestro/screenshots/android/extending_paragraph_style_on_paste_after_copy.png rename to .maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_copy.png diff --git a/.maestro/screenshots/android/extending_paragraph_style_on_paste_after_cut.png b/.maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_cut.png similarity index 100% rename from .maestro/screenshots/android/extending_paragraph_style_on_paste_after_cut.png rename to .maestro/enrichedInput/screenshots/android/extending_paragraph_style_on_paste_after_cut.png diff --git a/.maestro/screenshots/android/html_link_not_extended.png b/.maestro/enrichedInput/screenshots/android/html_link_not_extended.png similarity index 100% rename from .maestro/screenshots/android/html_link_not_extended.png rename to .maestro/enrichedInput/screenshots/android/html_link_not_extended.png diff --git a/.maestro/screenshots/android/image_position_stability_after_typing.png b/.maestro/enrichedInput/screenshots/android/image_position_stability_after_typing.png similarity index 100% rename from .maestro/screenshots/android/image_position_stability_after_typing.png rename to .maestro/enrichedInput/screenshots/android/image_position_stability_after_typing.png diff --git a/.maestro/screenshots/android/image_position_stability_before_typing.png b/.maestro/enrichedInput/screenshots/android/image_position_stability_before_typing.png similarity index 100% rename from .maestro/screenshots/android/image_position_stability_before_typing.png rename to .maestro/enrichedInput/screenshots/android/image_position_stability_before_typing.png diff --git a/.maestro/screenshots/android/initial_html_parsing.png b/.maestro/enrichedInput/screenshots/android/initial_html_parsing.png similarity index 100% rename from .maestro/screenshots/android/initial_html_parsing.png rename to .maestro/enrichedInput/screenshots/android/initial_html_parsing.png diff --git a/.maestro/screenshots/android/initial_placeholder.png b/.maestro/enrichedInput/screenshots/android/initial_placeholder.png similarity index 100% rename from .maestro/screenshots/android/initial_placeholder.png rename to .maestro/enrichedInput/screenshots/android/initial_placeholder.png diff --git a/.maestro/screenshots/android/inline_styles.png b/.maestro/enrichedInput/screenshots/android/inline_styles.png similarity index 100% rename from .maestro/screenshots/android/inline_styles.png rename to .maestro/enrichedInput/screenshots/android/inline_styles.png diff --git a/.maestro/screenshots/android/inline_styles_survive_block_toggle.png b/.maestro/enrichedInput/screenshots/android/inline_styles_survive_block_toggle.png similarity index 100% rename from .maestro/screenshots/android/inline_styles_survive_block_toggle.png rename to .maestro/enrichedInput/screenshots/android/inline_styles_survive_block_toggle.png diff --git a/.maestro/screenshots/android/link_not_extended.png b/.maestro/enrichedInput/screenshots/android/link_not_extended.png similarity index 100% rename from .maestro/screenshots/android/link_not_extended.png rename to .maestro/enrichedInput/screenshots/android/link_not_extended.png diff --git a/.maestro/screenshots/android/list_newline_insertion.png b/.maestro/enrichedInput/screenshots/android/list_newline_insertion.png similarity index 100% rename from .maestro/screenshots/android/list_newline_insertion.png rename to .maestro/enrichedInput/screenshots/android/list_newline_insertion.png diff --git a/.maestro/screenshots/android/mention_parsing_single_quoted_attributes.png b/.maestro/enrichedInput/screenshots/android/mention_parsing_single_quoted_attributes.png similarity index 100% rename from .maestro/screenshots/android/mention_parsing_single_quoted_attributes.png rename to .maestro/enrichedInput/screenshots/android/mention_parsing_single_quoted_attributes.png diff --git a/.maestro/screenshots/android/ordered_list_renumbering_after_deleting_second_line.png b/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_deleting_second_line.png similarity index 100% rename from .maestro/screenshots/android/ordered_list_renumbering_after_deleting_second_line.png rename to .maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_deleting_second_line.png diff --git a/.maestro/screenshots/android/ordered_list_renumbering_after_emptying_second_line.png b/.maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_emptying_second_line.png similarity index 100% rename from .maestro/screenshots/android/ordered_list_renumbering_after_emptying_second_line.png rename to .maestro/enrichedInput/screenshots/android/ordered_list_renumbering_after_emptying_second_line.png diff --git a/.maestro/screenshots/android/paragraph_style_after_removal.png b/.maestro/enrichedInput/screenshots/android/paragraph_style_after_removal.png similarity index 100% rename from .maestro/screenshots/android/paragraph_style_after_removal.png rename to .maestro/enrichedInput/screenshots/android/paragraph_style_after_removal.png diff --git a/.maestro/screenshots/android/paragraph_style_toggle.png b/.maestro/enrichedInput/screenshots/android/paragraph_style_toggle.png similarity index 100% rename from .maestro/screenshots/android/paragraph_style_toggle.png rename to .maestro/enrichedInput/screenshots/android/paragraph_style_toggle.png diff --git a/.maestro/screenshots/android/paragraph_styles_blocks.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png similarity index 100% rename from .maestro/screenshots/android/paragraph_styles_blocks.png rename to .maestro/enrichedInput/screenshots/android/paragraph_styles_blocks.png diff --git a/.maestro/screenshots/android/paragraph_styles_headings.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_headings.png similarity index 100% rename from .maestro/screenshots/android/paragraph_styles_headings.png rename to .maestro/enrichedInput/screenshots/android/paragraph_styles_headings.png diff --git a/.maestro/screenshots/android/paragraph_styles_lists.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png similarity index 100% rename from .maestro/screenshots/android/paragraph_styles_lists.png rename to .maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png diff --git a/.maestro/screenshots/android/paragraph_styles_no_crash.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png similarity index 100% rename from .maestro/screenshots/android/paragraph_styles_no_crash.png rename to .maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png diff --git a/.maestro/screenshots/android/scroll_after_typing_long_content_bottom.png b/.maestro/enrichedInput/screenshots/android/scroll_after_typing_long_content_bottom.png similarity index 100% rename from .maestro/screenshots/android/scroll_after_typing_long_content_bottom.png rename to .maestro/enrichedInput/screenshots/android/scroll_after_typing_long_content_bottom.png diff --git a/.maestro/screenshots/android/scroll_after_typing_long_content_top.png b/.maestro/enrichedInput/screenshots/android/scroll_after_typing_long_content_top.png similarity index 100% rename from .maestro/screenshots/android/scroll_after_typing_long_content_top.png rename to .maestro/enrichedInput/screenshots/android/scroll_after_typing_long_content_top.png diff --git a/.maestro/screenshots/android/scrolling_paragraph_styles_bottom.png b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png similarity index 100% rename from .maestro/screenshots/android/scrolling_paragraph_styles_bottom.png rename to .maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png diff --git a/.maestro/screenshots/android/scrolling_paragraph_styles_top.png b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png similarity index 100% rename from .maestro/screenshots/android/scrolling_paragraph_styles_top.png rename to .maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png diff --git a/.maestro/screenshots/android/scrolling_set_value_bottom.png b/.maestro/enrichedInput/screenshots/android/scrolling_set_value_bottom.png similarity index 100% rename from .maestro/screenshots/android/scrolling_set_value_bottom.png rename to .maestro/enrichedInput/screenshots/android/scrolling_set_value_bottom.png diff --git a/.maestro/screenshots/android/scrolling_set_value_top.png b/.maestro/enrichedInput/screenshots/android/scrolling_set_value_top.png similarity index 100% rename from .maestro/screenshots/android/scrolling_set_value_top.png rename to .maestro/enrichedInput/screenshots/android/scrolling_set_value_top.png diff --git a/.maestro/screenshots/ios/checkbox_toggle.png b/.maestro/enrichedInput/screenshots/ios/checkbox_toggle.png similarity index 100% rename from .maestro/screenshots/ios/checkbox_toggle.png rename to .maestro/enrichedInput/screenshots/ios/checkbox_toggle.png diff --git a/.maestro/screenshots/ios/codeblock_br_preservation.png b/.maestro/enrichedInput/screenshots/ios/codeblock_br_preservation.png similarity index 100% rename from .maestro/screenshots/ios/codeblock_br_preservation.png rename to .maestro/enrichedInput/screenshots/ios/codeblock_br_preservation.png diff --git a/.maestro/screenshots/ios/codeblock_no_link_detection.png b/.maestro/enrichedInput/screenshots/ios/codeblock_no_link_detection.png similarity index 100% rename from .maestro/screenshots/ios/codeblock_no_link_detection.png rename to .maestro/enrichedInput/screenshots/ios/codeblock_no_link_detection.png diff --git a/.maestro/screenshots/ios/codeblock_style_blocking.png b/.maestro/enrichedInput/screenshots/ios/codeblock_style_blocking.png similarity index 100% rename from .maestro/screenshots/ios/codeblock_style_blocking.png rename to .maestro/enrichedInput/screenshots/ios/codeblock_style_blocking.png diff --git a/.maestro/screenshots/ios/conflicting_paragraph_merge.png b/.maestro/enrichedInput/screenshots/ios/conflicting_paragraph_merge.png similarity index 100% rename from .maestro/screenshots/ios/conflicting_paragraph_merge.png rename to .maestro/enrichedInput/screenshots/ios/conflicting_paragraph_merge.png diff --git a/.maestro/screenshots/ios/empty_html_block_parsing.png b/.maestro/enrichedInput/screenshots/ios/empty_html_block_parsing.png similarity index 100% rename from .maestro/screenshots/ios/empty_html_block_parsing.png rename to .maestro/enrichedInput/screenshots/ios/empty_html_block_parsing.png diff --git a/.maestro/screenshots/ios/empty_lists_parsing.png b/.maestro/enrichedInput/screenshots/ios/empty_lists_parsing.png similarity index 100% rename from .maestro/screenshots/ios/empty_lists_parsing.png rename to .maestro/enrichedInput/screenshots/ios/empty_lists_parsing.png diff --git a/.maestro/screenshots/ios/html_link_not_extended.png b/.maestro/enrichedInput/screenshots/ios/html_link_not_extended.png similarity index 100% rename from .maestro/screenshots/ios/html_link_not_extended.png rename to .maestro/enrichedInput/screenshots/ios/html_link_not_extended.png diff --git a/.maestro/screenshots/ios/image_position_stability_after_typing.png b/.maestro/enrichedInput/screenshots/ios/image_position_stability_after_typing.png similarity index 100% rename from .maestro/screenshots/ios/image_position_stability_after_typing.png rename to .maestro/enrichedInput/screenshots/ios/image_position_stability_after_typing.png diff --git a/.maestro/screenshots/ios/image_position_stability_before_typing.png b/.maestro/enrichedInput/screenshots/ios/image_position_stability_before_typing.png similarity index 100% rename from .maestro/screenshots/ios/image_position_stability_before_typing.png rename to .maestro/enrichedInput/screenshots/ios/image_position_stability_before_typing.png diff --git a/.maestro/screenshots/ios/initial_html_parsing.png b/.maestro/enrichedInput/screenshots/ios/initial_html_parsing.png similarity index 100% rename from .maestro/screenshots/ios/initial_html_parsing.png rename to .maestro/enrichedInput/screenshots/ios/initial_html_parsing.png diff --git a/.maestro/screenshots/ios/initial_placeholder.png b/.maestro/enrichedInput/screenshots/ios/initial_placeholder.png similarity index 100% rename from .maestro/screenshots/ios/initial_placeholder.png rename to .maestro/enrichedInput/screenshots/ios/initial_placeholder.png diff --git a/.maestro/screenshots/ios/inline_styles.png b/.maestro/enrichedInput/screenshots/ios/inline_styles.png similarity index 100% rename from .maestro/screenshots/ios/inline_styles.png rename to .maestro/enrichedInput/screenshots/ios/inline_styles.png diff --git a/.maestro/screenshots/ios/inline_styles_survive_block_toggle.png b/.maestro/enrichedInput/screenshots/ios/inline_styles_survive_block_toggle.png similarity index 100% rename from .maestro/screenshots/ios/inline_styles_survive_block_toggle.png rename to .maestro/enrichedInput/screenshots/ios/inline_styles_survive_block_toggle.png diff --git a/.maestro/screenshots/ios/link_not_extended.png b/.maestro/enrichedInput/screenshots/ios/link_not_extended.png similarity index 100% rename from .maestro/screenshots/ios/link_not_extended.png rename to .maestro/enrichedInput/screenshots/ios/link_not_extended.png diff --git a/.maestro/screenshots/ios/list_newline_insertion.png b/.maestro/enrichedInput/screenshots/ios/list_newline_insertion.png similarity index 100% rename from .maestro/screenshots/ios/list_newline_insertion.png rename to .maestro/enrichedInput/screenshots/ios/list_newline_insertion.png diff --git a/.maestro/screenshots/ios/mention_parsing_single_quoted_attributes.png b/.maestro/enrichedInput/screenshots/ios/mention_parsing_single_quoted_attributes.png similarity index 100% rename from .maestro/screenshots/ios/mention_parsing_single_quoted_attributes.png rename to .maestro/enrichedInput/screenshots/ios/mention_parsing_single_quoted_attributes.png diff --git a/.maestro/screenshots/ios/ordered_list_renumbering_after_deleting_second_line.png b/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_deleting_second_line.png similarity index 100% rename from .maestro/screenshots/ios/ordered_list_renumbering_after_deleting_second_line.png rename to .maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_deleting_second_line.png diff --git a/.maestro/screenshots/ios/ordered_list_renumbering_after_emptying_second_line.png b/.maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_emptying_second_line.png similarity index 100% rename from .maestro/screenshots/ios/ordered_list_renumbering_after_emptying_second_line.png rename to .maestro/enrichedInput/screenshots/ios/ordered_list_renumbering_after_emptying_second_line.png diff --git a/.maestro/screenshots/ios/paragraph_style_after_removal.png b/.maestro/enrichedInput/screenshots/ios/paragraph_style_after_removal.png similarity index 100% rename from .maestro/screenshots/ios/paragraph_style_after_removal.png rename to .maestro/enrichedInput/screenshots/ios/paragraph_style_after_removal.png diff --git a/.maestro/screenshots/ios/paragraph_style_toggle.png b/.maestro/enrichedInput/screenshots/ios/paragraph_style_toggle.png similarity index 100% rename from .maestro/screenshots/ios/paragraph_style_toggle.png rename to .maestro/enrichedInput/screenshots/ios/paragraph_style_toggle.png diff --git a/.maestro/screenshots/ios/paragraph_styles_blocks.png b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_blocks.png similarity index 100% rename from .maestro/screenshots/ios/paragraph_styles_blocks.png rename to .maestro/enrichedInput/screenshots/ios/paragraph_styles_blocks.png diff --git a/.maestro/screenshots/ios/paragraph_styles_headings.png b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_headings.png similarity index 100% rename from .maestro/screenshots/ios/paragraph_styles_headings.png rename to .maestro/enrichedInput/screenshots/ios/paragraph_styles_headings.png diff --git a/.maestro/screenshots/ios/paragraph_styles_lists.png b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_lists.png similarity index 100% rename from .maestro/screenshots/ios/paragraph_styles_lists.png rename to .maestro/enrichedInput/screenshots/ios/paragraph_styles_lists.png diff --git a/.maestro/screenshots/ios/paragraph_styles_no_crash.png b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_no_crash.png similarity index 100% rename from .maestro/screenshots/ios/paragraph_styles_no_crash.png rename to .maestro/enrichedInput/screenshots/ios/paragraph_styles_no_crash.png diff --git a/.maestro/screenshots/ios/scroll_after_typing_long_content_bottom.png b/.maestro/enrichedInput/screenshots/ios/scroll_after_typing_long_content_bottom.png similarity index 100% rename from .maestro/screenshots/ios/scroll_after_typing_long_content_bottom.png rename to .maestro/enrichedInput/screenshots/ios/scroll_after_typing_long_content_bottom.png diff --git a/.maestro/screenshots/ios/scroll_after_typing_long_content_top.png b/.maestro/enrichedInput/screenshots/ios/scroll_after_typing_long_content_top.png similarity index 100% rename from .maestro/screenshots/ios/scroll_after_typing_long_content_top.png rename to .maestro/enrichedInput/screenshots/ios/scroll_after_typing_long_content_top.png diff --git a/.maestro/screenshots/ios/scrolling_paragraph_styles_bottom.png b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png similarity index 100% rename from .maestro/screenshots/ios/scrolling_paragraph_styles_bottom.png rename to .maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png diff --git a/.maestro/screenshots/ios/scrolling_paragraph_styles_top.png b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png similarity index 100% rename from .maestro/screenshots/ios/scrolling_paragraph_styles_top.png rename to .maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png diff --git a/.maestro/screenshots/ios/scrolling_set_value_bottom.png b/.maestro/enrichedInput/screenshots/ios/scrolling_set_value_bottom.png similarity index 100% rename from .maestro/screenshots/ios/scrolling_set_value_bottom.png rename to .maestro/enrichedInput/screenshots/ios/scrolling_set_value_bottom.png diff --git a/.maestro/screenshots/ios/scrolling_set_value_top.png b/.maestro/enrichedInput/screenshots/ios/scrolling_set_value_top.png similarity index 100% rename from .maestro/screenshots/ios/scrolling_set_value_top.png rename to .maestro/enrichedInput/screenshots/ios/scrolling_set_value_top.png diff --git a/.maestro/enrichedInput/subflows/capture_or_assert_screenshot.yaml b/.maestro/enrichedInput/subflows/capture_or_assert_screenshot.yaml new file mode 100644 index 000000000..ea7b9e800 --- /dev/null +++ b/.maestro/enrichedInput/subflows/capture_or_assert_screenshot.yaml @@ -0,0 +1,10 @@ +appId: swmansion.enriched.example +--- +- tapOn: + id: 'blur-button' + +- runFlow: + file: '../../subflows/capture_or_assert_screenshot.yaml' + env: + ELEMENT_ID: 'editor-input' + SCREENSHOT_PREFIX: 'enrichedInput' diff --git a/.maestro/subflows/insert_image.yaml b/.maestro/enrichedInput/subflows/insert_image.yaml similarity index 88% rename from .maestro/subflows/insert_image.yaml rename to .maestro/enrichedInput/subflows/insert_image.yaml index 31fb1f3c0..d8804279e 100644 --- a/.maestro/subflows/insert_image.yaml +++ b/.maestro/enrichedInput/subflows/insert_image.yaml @@ -11,7 +11,7 @@ appId: swmansion.enriched.example timeout: 10000 - addMedia: - - "../assets/sample_image.jpg" + - "../../assets/sample_image.jpg" - tapOn: id: "image-modal-submit-button" diff --git a/.maestro/subflows/insert_link.yaml b/.maestro/enrichedInput/subflows/insert_link.yaml similarity index 100% rename from .maestro/subflows/insert_link.yaml rename to .maestro/enrichedInput/subflows/insert_link.yaml diff --git a/.maestro/subflows/pick_first_image.yaml b/.maestro/enrichedInput/subflows/pick_first_image.yaml similarity index 100% rename from .maestro/subflows/pick_first_image.yaml rename to .maestro/enrichedInput/subflows/pick_first_image.yaml diff --git a/.maestro/subflows/set_editor_value.yaml b/.maestro/enrichedInput/subflows/set_editor_value.yaml similarity index 100% rename from .maestro/subflows/set_editor_value.yaml rename to .maestro/enrichedInput/subflows/set_editor_value.yaml diff --git a/.maestro/enrichedText/flows/custom_styles_display.yaml b/.maestro/enrichedText/flows/custom_styles_display.yaml new file mode 100644 index 000000000..84c65a802 --- /dev/null +++ b/.maestro/enrichedText/flows/custom_styles_display.yaml @@ -0,0 +1,25 @@ +appId: swmansion.enriched.example +--- +# Validates that custom styles are displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +

Link

+

@John Doe

+

+ + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_styles_display' diff --git a/.maestro/enrichedText/flows/empty_list_elements_display.yaml b/.maestro/enrichedText/flows/empty_list_elements_display.yaml new file mode 100644 index 000000000..6344898c1 --- /dev/null +++ b/.maestro/enrichedText/flows/empty_list_elements_display.yaml @@ -0,0 +1,39 @@ +appId: swmansion.enriched.example +tags: + - android-only +--- +# Validates that lists with empty items are parsed and displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +
    +
  1. First
  2. +
  3. +
  4. Third
  5. +
+ + + + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'empty_list_elements_display' diff --git a/.maestro/enrichedText/flows/inline_styles_display.yaml b/.maestro/enrichedText/flows/inline_styles_display.yaml new file mode 100644 index 000000000..32c21b08c --- /dev/null +++ b/.maestro/enrichedText/flows/inline_styles_display.yaml @@ -0,0 +1,27 @@ +appId: swmansion.enriched.example +tags: + - android-only +--- +# Validates that inline styles are displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +

Plain text

+

Bold Italic Underline Strike Code

+

Combined

+ + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'inline_styles_display' diff --git a/.maestro/enrichedText/flows/link_press.yaml b/.maestro/enrichedText/flows/link_press.yaml new file mode 100644 index 000000000..7b232aad0 --- /dev/null +++ b/.maestro/enrichedText/flows/link_press.yaml @@ -0,0 +1,25 @@ +appId: swmansion.enriched.example +tags: + - android-only +--- +# Validates that link press events are triggered correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: '

Link

' + +- tapOn: + text: 'Link' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'link_press' diff --git a/.maestro/enrichedText/flows/mention_press.yaml b/.maestro/enrichedText/flows/mention_press.yaml new file mode 100644 index 000000000..0133f49c0 --- /dev/null +++ b/.maestro/enrichedText/flows/mention_press.yaml @@ -0,0 +1,25 @@ +appId: swmansion.enriched.example +tags: + - android-only +--- +# Validates that mention press events are triggered correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: '

@John Doe

' + +- tapOn: + text: '@John Doe' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'mention_press' diff --git a/.maestro/enrichedText/flows/paragraph_styles_display.yaml b/.maestro/enrichedText/flows/paragraph_styles_display.yaml new file mode 100644 index 000000000..434e4101a --- /dev/null +++ b/.maestro/enrichedText/flows/paragraph_styles_display.yaml @@ -0,0 +1,45 @@ +appId: swmansion.enriched.example +tags: + - android-only +--- +# Validates that paragraph styles are displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +

Heading 1

+

Heading 2

+

Heading 3

+

Heading 4

+
Heading 5
+
Heading 6
+ +

Code block

+
+
+

Blockquote

+
+ +
    +
  1. Ordered list
  2. +
+ + + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'paragraph_styles_display' diff --git a/.maestro/enrichedText/screenshots/android/custom_styles_display.png b/.maestro/enrichedText/screenshots/android/custom_styles_display.png new file mode 100644 index 000000000..97f2e6a81 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/custom_styles_display.png differ diff --git a/.maestro/enrichedText/screenshots/android/empty_list_elements_display.png b/.maestro/enrichedText/screenshots/android/empty_list_elements_display.png new file mode 100644 index 000000000..62859c08e Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/empty_list_elements_display.png differ diff --git a/.maestro/enrichedText/screenshots/android/inline_styles_display.png b/.maestro/enrichedText/screenshots/android/inline_styles_display.png new file mode 100644 index 000000000..fe658211f Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/inline_styles_display.png differ diff --git a/.maestro/enrichedText/screenshots/android/link_press.png b/.maestro/enrichedText/screenshots/android/link_press.png new file mode 100644 index 000000000..0a6f60821 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/link_press.png differ diff --git a/.maestro/enrichedText/screenshots/android/mention_press.png b/.maestro/enrichedText/screenshots/android/mention_press.png new file mode 100644 index 000000000..d480c2e1e Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/mention_press.png differ diff --git a/.maestro/enrichedText/screenshots/android/paragraph_styles_display.png b/.maestro/enrichedText/screenshots/android/paragraph_styles_display.png new file mode 100644 index 000000000..8a24dc648 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/paragraph_styles_display.png differ diff --git a/.maestro/enrichedText/subflows/capture_or_assert_screenshot.yaml b/.maestro/enrichedText/subflows/capture_or_assert_screenshot.yaml new file mode 100644 index 000000000..e64040d88 --- /dev/null +++ b/.maestro/enrichedText/subflows/capture_or_assert_screenshot.yaml @@ -0,0 +1,7 @@ +appId: swmansion.enriched.example +--- +- runFlow: + file: '../../subflows/capture_or_assert_screenshot.yaml' + env: + ELEMENT_ID: 'enriched-text' + SCREENSHOT_PREFIX: 'enrichedText' diff --git a/.maestro/enrichedText/subflows/set_enriched_text_value.yaml b/.maestro/enrichedText/subflows/set_enriched_text_value.yaml new file mode 100644 index 000000000..b03f48bcd --- /dev/null +++ b/.maestro/enrichedText/subflows/set_enriched_text_value.yaml @@ -0,0 +1,19 @@ +appId: swmansion.enriched.example +--- + +- tapOn: + id: "set-enriched-text-button" + +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: "value-modal-input" + timeout: 10000 + +- tapOn: + id: "value-modal-input" + +- inputText: ${VALUE} + +- tapOn: + id: "value-modal-submit-button" diff --git a/.maestro/scripts/run-tests.sh b/.maestro/scripts/run-tests.sh index 0e6c64dd6..4e9230d64 100755 --- a/.maestro/scripts/run-tests.sh +++ b/.maestro/scripts/run-tests.sh @@ -10,11 +10,11 @@ # --rebuild Force a rebuild and install, even if the app is # already installed on the device. # flow ... One or more Maestro flow files or directories to run. -# Defaults to .maestro/flows if omitted. +# Defaults to all component suites if omitted. # # Examples: # ./run-tests.sh --platform ios -# ./run-tests.sh --platform android --update-screenshots .maestro/flows/core_controls_smoke.yaml +# ./run-tests.sh --platform android --update-screenshots .maestro/enrichedInput/flows/core_controls_smoke.yaml # ./run-tests.sh --platform ios --rebuild set -euo pipefail @@ -35,7 +35,8 @@ fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -SCREENSHOT_ROOT="$REPO_ROOT/.maestro/screenshots" +MAESTRO_ROOT="$REPO_ROOT/.maestro" +SCREENSHOT_ROOT="$MAESTRO_ROOT" BUNDLE_ID="swmansion.enriched.example" PLATFORM="" @@ -52,7 +53,7 @@ while [ $# -gt 0 ]; do esac done -[ -z "$FLOWS" ] && FLOWS=".maestro/flows" +[ -z "$FLOWS" ] && FLOWS=".maestro/enrichedInput/flows .maestro/enrichedText/flows" case "$PLATFORM" in ios) SETUP="$SCRIPT_DIR/setup-ios-simulator.sh" ;; @@ -93,7 +94,7 @@ esac # Maestro resolves addMedia paths by walking the workspace inputs. Since assets # live outside the flows directory, always include it so media files are found. -ASSETS_DIR=".maestro/assets" +ASSETS_DIR="$MAESTRO_ROOT/assets" [ -d "$ASSETS_DIR" ] && FLOWS="$ASSETS_DIR $FLOWS" echo "=== Running maestro tests ===" diff --git a/.maestro/subflows/capture_or_assert_screenshot.yaml b/.maestro/subflows/capture_or_assert_screenshot.yaml index 7db6eb833..ffb517426 100644 --- a/.maestro/subflows/capture_or_assert_screenshot.yaml +++ b/.maestro/subflows/capture_or_assert_screenshot.yaml @@ -1,23 +1,20 @@ appId: swmansion.enriched.example --- -- tapOn: - id: "blur-button" - - runFlow: when: true: ${UPDATE_SCREENSHOTS === "true"} commands: - takeScreenshot: - path: "${SCREENSHOT_ROOT}/${maestro.platform}/${SCREENSHOT_NAME}" + path: "${SCREENSHOT_ROOT}/${SCREENSHOT_PREFIX}/screenshots/${maestro.platform}/${SCREENSHOT_NAME}" cropOn: - id: "editor-input" + id: "${ELEMENT_ID}" - runFlow: when: true: ${UPDATE_SCREENSHOTS !== "true"} commands: - assertScreenshot: - path: "${SCREENSHOT_ROOT}/${maestro.platform}/${SCREENSHOT_NAME}" + path: "${SCREENSHOT_ROOT}/${SCREENSHOT_PREFIX}/screenshots/${maestro.platform}/${SCREENSHOT_NAME}" thresholdPercentage: 100 cropOn: - id: "editor-input" + id: "${ELEMENT_ID}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afcb74d59..d8582ee47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,7 +76,7 @@ yarn test ### E2E tests -We use [Maestro](https://maestro.mobile.dev/) for end-to-end testing. Flows live in `.maestro/flows/` and shared subflows in `.maestro/subflows/`. +We use [Maestro](https://maestro.mobile.dev/) for end-to-end testing. Flows live in `.maestro/enrichedInput/flows/` and `.maestro/enrichedText/flows/`. Shared subflows live in `.maestro/subflows/`, with component-specific subflows in `.maestro/enrichedInput/subflows/` and `.maestro/enrichedText/subflows/`. #### Prerequisites @@ -117,7 +117,7 @@ You can target specific flows or force a rebuild: ```sh # Run a single flow -yarn test:e2e:ios .maestro/flows/core_controls_smoke.yaml +yarn test:e2e:ios .maestro/enrichedInput/flows/core_controls_smoke.yaml # Force a fresh build even if the app is already installed yarn test:e2e:android --rebuild @@ -125,7 +125,7 @@ yarn test:e2e:android --rebuild #### Visual regression tests -Some flows compare a screenshot of the editor against a saved baseline in `.maestro/screenshots/`. By default the baseline is asserted. Pass `--update-screenshots` to capture new baselines instead: +Some flows compare a screenshot of the editor against a saved baseline in `.maestro/enrichedInput/screenshots/` or `.maestro/enrichedText/screenshots/`. By default the baseline is asserted. Pass `--update-screenshots` to capture new baselines instead: ```sh # Update baselines on both platforms @@ -133,10 +133,10 @@ yarn test:e2e:mobile --update-screenshots # Single platform yarn test:e2e:ios --update-screenshots -yarn test:e2e:android --update-screenshots .maestro/flows/inline_styles_visual.yaml +yarn test:e2e:android --update-screenshots .maestro/enrichedInput/flows/inline_styles_visual.yaml ``` -Always review newly saved screenshots in `.maestro/screenshots/` before committing them. +Always review newly saved screenshots in `.maestro/enrichedInput/screenshots/` and `.maestro/enrichedText/screenshots/` before committing them. #### Troubleshooting: flaky Android tests on macOS diff --git a/android/src/main/java/com/swmansion/enriched/ReactNativeEnrichedPackage.kt b/android/src/main/java/com/swmansion/enriched/ReactNativeEnrichedPackage.kt index d9383356d..e65ceba30 100644 --- a/android/src/main/java/com/swmansion/enriched/ReactNativeEnrichedPackage.kt +++ b/android/src/main/java/com/swmansion/enriched/ReactNativeEnrichedPackage.kt @@ -5,6 +5,7 @@ import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ViewManager import com.swmansion.enriched.common.ResourceManager +import com.swmansion.enriched.text.EnrichedTextViewManager import com.swmansion.enriched.textinput.EnrichedTextInputViewManager import java.util.ArrayList @@ -13,6 +14,7 @@ class ReactNativeEnrichedPackage : ReactPackage { ResourceManager.init(reactContext.applicationContext) val viewManagers: MutableList> = ArrayList() viewManagers.add(EnrichedTextInputViewManager()) + viewManagers.add(EnrichedTextViewManager()) return viewManagers } diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedConstants.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedConstants.kt index e89548726..353e7f02e 100644 --- a/android/src/main/java/com/swmansion/enriched/common/EnrichedConstants.kt +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedConstants.kt @@ -8,4 +8,6 @@ object EnrichedConstants { // Object Replacement Character const val ORC = '\uFFFC' const val ORC_STRING = "\uFFFC" + + const val TEXT_DEFAULT_FONT_SIZE = 16f } diff --git a/android/src/main/java/com/swmansion/enriched/common/MentionStyle.kt b/android/src/main/java/com/swmansion/enriched/common/MentionStyle.kt index e94262350..e5a7d7c13 100644 --- a/android/src/main/java/com/swmansion/enriched/common/MentionStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/common/MentionStyle.kt @@ -4,4 +4,6 @@ data class MentionStyle( val color: Int, val backgroundColor: Int, val underline: Boolean, + val pressColor: Int? = null, + val pressBackgroundColor: Int? = null, ) diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedImageSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedImageSpan.kt index ee1665e32..ba09436ce 100644 --- a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedImageSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedImageSpan.kt @@ -10,16 +10,13 @@ import android.graphics.drawable.Drawable import android.os.Build import android.os.Handler import android.os.Looper -import android.text.Editable import android.text.Spannable import android.text.style.ImageSpan import android.util.Log import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.withSave -import com.swmansion.enriched.R import com.swmansion.enriched.common.AsyncDrawable import com.swmansion.enriched.common.ForceRedrawSpan -import com.swmansion.enriched.common.ResourceManager import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan import java.io.File @@ -90,10 +87,10 @@ open class EnrichedImageSpan : private fun registerDrawableLoadCallback( d: AsyncDrawable, - text: Editable?, + text: Spannable?, ) { d.onLoaded = onLoaded@{ - val spannable = text as? Spannable + val spannable = text if (spannable == null) { return@onLoaded @@ -113,7 +110,7 @@ open class EnrichedImageSpan : } } - fun observeAsyncDrawableLoaded(text: Editable?) { + fun observeAsyncDrawableLoaded(text: Spannable?) { val d = drawable if (d !is AsyncDrawable) { diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextMovementMethod.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextMovementMethod.kt new file mode 100644 index 000000000..ac8b9f440 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextMovementMethod.kt @@ -0,0 +1,75 @@ +package com.swmansion.enriched.text + +import android.text.Selection +import android.text.Spannable +import android.text.method.LinkMovementMethod +import android.view.MotionEvent +import android.widget.TextView +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextClickableSpan + +class EnrichedTextMovementMethod : LinkMovementMethod() { + override fun onTouchEvent( + widget: TextView, + buffer: Spannable, + event: MotionEvent, + ): Boolean { + val action = event.action + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_CANCEL) { + val x = (event.x - widget.totalPaddingLeft + widget.scrollX).toInt() + val y = (event.y - widget.totalPaddingTop + widget.scrollY).toInt() + + val layout = widget.layout + val line = layout.getLineForVertical(y) + val off = layout.getOffsetForHorizontal(line, x.toFloat()) + + val inLineBounds = x >= layout.getLineLeft(line) && x <= layout.getLineRight(line) + val links = + if (inLineBounds) { + buffer.getSpans(off, off, EnrichedTextClickableSpan::class.java) + } else { + emptyArray() + } + + if (links.isNotEmpty()) { + val link = links[0] + + when (action) { + MotionEvent.ACTION_DOWN -> { + link.isPressed = true + Selection.setSelection(buffer, buffer.getSpanStart(link), buffer.getSpanEnd(link)) + } + + MotionEvent.ACTION_UP -> { + link.onClick(widget) + link.isPressed = false + Selection.removeSelection(buffer) + } + + MotionEvent.ACTION_CANCEL -> { + link.isPressed = false + Selection.removeSelection(buffer) + } + } + + widget.invalidate() + return true + } else { + val allSpans = buffer.getSpans(0, buffer.length, EnrichedTextClickableSpan::class.java) + allSpans.forEach { it.isPressed = false } + Selection.removeSelection(buffer) + widget.invalidate() + } + } + return false + } + + companion object { + private var instance: EnrichedTextMovementMethod? = null + + fun getInstance(): EnrichedTextMovementMethod { + if (instance == null) instance = EnrichedTextMovementMethod() + return instance!! + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt new file mode 100644 index 000000000..373b169fb --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt @@ -0,0 +1,80 @@ +package com.swmansion.enriched.text + +import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.text.spans.EnrichedTextBlockQuoteSpan +import com.swmansion.enriched.text.spans.EnrichedTextBoldSpan +import com.swmansion.enriched.text.spans.EnrichedTextCheckboxListSpan +import com.swmansion.enriched.text.spans.EnrichedTextCodeBlockSpan +import com.swmansion.enriched.text.spans.EnrichedTextH1Span +import com.swmansion.enriched.text.spans.EnrichedTextH2Span +import com.swmansion.enriched.text.spans.EnrichedTextH3Span +import com.swmansion.enriched.text.spans.EnrichedTextH4Span +import com.swmansion.enriched.text.spans.EnrichedTextH5Span +import com.swmansion.enriched.text.spans.EnrichedTextH6Span +import com.swmansion.enriched.text.spans.EnrichedTextImageSpan +import com.swmansion.enriched.text.spans.EnrichedTextInlineCodeSpan +import com.swmansion.enriched.text.spans.EnrichedTextItalicSpan +import com.swmansion.enriched.text.spans.EnrichedTextLinkSpan +import com.swmansion.enriched.text.spans.EnrichedTextMentionSpan +import com.swmansion.enriched.text.spans.EnrichedTextOrderedListSpan +import com.swmansion.enriched.text.spans.EnrichedTextStrikeThroughSpan +import com.swmansion.enriched.text.spans.EnrichedTextUnderlineSpan +import com.swmansion.enriched.text.spans.EnrichedTextUnorderedListSpan + +class EnrichedTextSpanFactory : EnrichedSpanFactory { + override fun createBoldSpan(style: EnrichedTextStyle) = EnrichedTextBoldSpan(style) + + override fun createItalicSpan(style: EnrichedTextStyle) = EnrichedTextItalicSpan(style) + + override fun createUnderlineSpan(style: EnrichedTextStyle) = EnrichedTextUnderlineSpan(style) + + override fun createStrikeThroughSpan(style: EnrichedTextStyle) = EnrichedTextStrikeThroughSpan(style) + + override fun createInlineCodeSpan(style: EnrichedTextStyle) = EnrichedTextInlineCodeSpan(style) + + override fun createLinkSpan( + url: String, + style: EnrichedTextStyle, + ) = EnrichedTextLinkSpan(url, style) + + override fun createMentionSpan( + text: String, + indicator: String, + attributes: Map, + style: EnrichedTextStyle, + ) = EnrichedTextMentionSpan(text, indicator, attributes, style) + + override fun createImageSpan( + source: String, + width: Int, + height: Int, + ) = EnrichedTextImageSpan.createEnrichedImageSpan(source, width, height) + + override fun createH1Span(style: EnrichedTextStyle) = EnrichedTextH1Span(style) + + override fun createH2Span(style: EnrichedTextStyle) = EnrichedTextH2Span(style) + + override fun createH3Span(style: EnrichedTextStyle) = EnrichedTextH3Span(style) + + override fun createH4Span(style: EnrichedTextStyle) = EnrichedTextH4Span(style) + + override fun createH5Span(style: EnrichedTextStyle) = EnrichedTextH5Span(style) + + override fun createH6Span(style: EnrichedTextStyle) = EnrichedTextH6Span(style) + + override fun createOrderedListSpan( + index: Int, + style: EnrichedTextStyle, + ) = EnrichedTextOrderedListSpan(index, style) + + override fun createUnorderedListSpan(style: EnrichedTextStyle) = EnrichedTextUnorderedListSpan(style) + + override fun createCheckboxListSpan( + isChecked: Boolean, + style: EnrichedTextStyle, + ) = EnrichedTextCheckboxListSpan(isChecked, style) + + override fun createBlockQuoteSpan(style: EnrichedTextStyle) = EnrichedTextBlockQuoteSpan(style) + + override fun createCodeBlockSpan(style: EnrichedTextStyle) = EnrichedTextCodeBlockSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt new file mode 100644 index 000000000..e6d6c7461 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt @@ -0,0 +1,200 @@ +package com.swmansion.enriched.text + +import android.graphics.Color +import com.facebook.react.bridge.ColorPropConverter +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight +import com.swmansion.enriched.common.EnrichedStyle +import com.swmansion.enriched.common.MentionStyle +import kotlin.math.ceil + +data class EnrichedTextStyle( + // Headings + override val h1FontSize: Int, + override val h1Bold: Boolean, + override val h2FontSize: Int, + override val h2Bold: Boolean, + override val h3FontSize: Int, + override val h3Bold: Boolean, + override val h4FontSize: Int, + override val h4Bold: Boolean, + override val h5FontSize: Int, + override val h5Bold: Boolean, + override val h6FontSize: Int, + override val h6Bold: Boolean, + // Blockquote + override val blockquoteColor: Int?, + override val blockquoteBorderColor: Int, + override val blockquoteStripeWidth: Int, + override val blockquoteGapWidth: Int, + // Ordered List + override val olGapWidth: Int, + override val olMarginLeft: Int, + override val olMarkerFontWeight: Int?, + override val olMarkerColor: Int?, + // Unordered List + override val ulGapWidth: Int, + override val ulMarginLeft: Int, + override val ulBulletSize: Int, + override val ulBulletColor: Int, + // Checkbox List + override val ulCheckboxBoxColor: Int, + override val ulCheckboxBoxSize: Int, + override val ulCheckboxGapWidth: Int, + override val ulCheckboxMarginLeft: Int, + // Links + override val aColor: Int, + override val aUnderline: Boolean, + val aPressColor: Int, + // Code Blocks + override val codeBlockColor: Int, + override val codeBlockBackgroundColor: Int, + override val codeBlockRadius: Float, + // Inline Code + override val inlineCodeColor: Int, + override val inlineCodeBackgroundColor: Int, + // Mentions + override val mentionsStyle: Map, +) : EnrichedStyle { + companion object { + fun fromReadableMap( + context: ReactContext, + fontSize: Int, + map: ReadableMap, + ): EnrichedTextStyle { + val h1 = map.getMap("h1") + val h2 = map.getMap("h2") + val h3 = map.getMap("h3") + val h4 = map.getMap("h4") + val h5 = map.getMap("h5") + val h6 = map.getMap("h6") + val blockquote = map.getMap("blockquote") + val orderedList = map.getMap("ol") + val unorderedList = map.getMap("ul") + val checkboxList = map.getMap("ulCheckbox") + val link = map.getMap("a") + val codeblock = map.getMap("codeblock") + val inlineCode = map.getMap("code") + val mentions = map.getMap("mention") + + return EnrichedTextStyle( + h1FontSize = parseFloat(h1, "fontSize").toInt(), + h1Bold = h1?.getBoolean("bold") ?: false, + h2FontSize = parseFloat(h2, "fontSize").toInt(), + h2Bold = h2?.getBoolean("bold") ?: false, + h3FontSize = parseFloat(h3, "fontSize").toInt(), + h3Bold = h3?.getBoolean("bold") ?: false, + h4FontSize = parseFloat(h4, "fontSize").toInt(), + h4Bold = h4?.getBoolean("bold") ?: false, + h5FontSize = parseFloat(h5, "fontSize").toInt(), + h5Bold = h5?.getBoolean("bold") ?: false, + h6FontSize = parseFloat(h6, "fontSize").toInt(), + h6Bold = h6?.getBoolean("bold") ?: false, + blockquoteColor = parseOptionalColor(context, blockquote, "color"), + blockquoteBorderColor = parseColor(context, blockquote, "borderColor"), + blockquoteStripeWidth = parseFloat(blockquote, "borderWidth").toInt(), + blockquoteGapWidth = parseFloat(blockquote, "gapWidth").toInt(), + olGapWidth = parseFloat(orderedList, "gapWidth").toInt(), + olMarginLeft = calculateOlMarginLeft(fontSize, parseFloat(orderedList, "marginLeft").toInt()), + olMarkerFontWeight = parseOptionalFontWeight(orderedList, "markerFontWeight"), + olMarkerColor = parseOptionalColor(context, orderedList, "markerColor"), + ulGapWidth = parseFloat(unorderedList, "gapWidth").toInt(), + ulMarginLeft = parseFloat(unorderedList, "marginLeft").toInt(), + ulBulletSize = parseFloat(unorderedList, "bulletSize").toInt(), + ulBulletColor = parseColor(context, unorderedList, "bulletColor"), + ulCheckboxBoxColor = parseColor(context, checkboxList, "boxColor"), + ulCheckboxBoxSize = parseFloat(checkboxList, "boxSize").toInt(), + ulCheckboxGapWidth = parseFloat(checkboxList, "gapWidth").toInt(), + ulCheckboxMarginLeft = parseFloat(checkboxList, "marginLeft").toInt(), + aColor = parseColor(context, link, "color"), + aUnderline = parseIsUnderline(link), + aPressColor = parseColor(context, link, "pressColor"), + codeBlockColor = parseColor(context, codeblock, "color"), + codeBlockBackgroundColor = parseColorWithOpacity(context, codeblock, "backgroundColor", 80), + codeBlockRadius = parseFloat(codeblock, "borderRadius"), + inlineCodeColor = parseColor(context, inlineCode, "color"), + inlineCodeBackgroundColor = parseColorWithOpacity(context, inlineCode, "backgroundColor", 80), + mentionsStyle = parseMentionsStyle(context, mentions), + ) + } + + private fun parseFloat( + map: ReadableMap?, + key: String, + ): Float { + if (map == null || !map.hasKey(key) || map.isNull(key)) return 0f + return ceil(PixelUtil.toPixelFromSP(map.getDouble(key))) + } + + private fun parseColor( + context: ReactContext, + map: ReadableMap?, + key: String, + ): Int { + val colorDouble = map?.getDouble(key) ?: throw Error("Key $key is missing or null") + return ColorPropConverter.getColor(colorDouble, context) ?: Color.BLACK + } + + private fun parseOptionalColor( + context: ReactContext, + map: ReadableMap?, + key: String, + ): Int? { + if (map == null || !map.hasKey(key) || map.isNull(key)) return null + return ColorPropConverter.getColor(map.getDouble(key), context) + } + + private fun parseColorWithOpacity( + context: ReactContext, + map: ReadableMap?, + key: String, + opacity: Int, + ): Int { + val color = parseColor(context, map, key) + if (Color.alpha(color) == 0) return color + return (color and 0x00FFFFFF) or (opacity.coerceIn(0, 255) shl 24) + } + + private fun parseIsUnderline(map: ReadableMap?): Boolean = map?.getString("textDecorationLine") == "underline" + + private fun parseOptionalFontWeight( + map: ReadableMap?, + key: String, + ): Int? { + val weight = map?.getString(key) ?: return null + 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?, + ): Map { + val result = mutableMapOf() + val iterator = map?.keySetIterator() ?: return result + while (iterator.hasNextKey()) { + val key = iterator.nextKey() + val value = map.getMap(key) ?: continue + + result[key] = + MentionStyle( + color = parseColor(context, value, "color"), + backgroundColor = parseColorWithOpacity(context, value, "backgroundColor", 80), + underline = parseIsUnderline(value), + pressColor = parseColor(context, value, "pressColor"), + pressBackgroundColor = parseColorWithOpacity(context, value, "pressBackgroundColor", 80), + ) + } + return result + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt new file mode 100644 index 000000000..2655b1ffd --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt @@ -0,0 +1,212 @@ +package com.swmansion.enriched.text + +import android.content.Context +import android.graphics.Color +import android.graphics.text.LineBreaker +import android.os.Build +import android.text.Spannable +import android.text.SpannableString +import android.text.TextUtils +import android.util.AttributeSet +import android.util.TypedValue +import androidx.appcompat.widget.AppCompatTextView +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.common.ReactConstants +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.ViewDefaults +import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight +import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.parser.EnrichedParser +import com.swmansion.enriched.text.spans.EnrichedTextImageSpan +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan +import kotlin.math.ceil + +class EnrichedTextView : AppCompatTextView { + private var valueDirty = false + private var value: String? = null + private var typefaceDirty = false + private var fontFamily: String? = null + private var fontStyle: Int = ReactConstants.UNSET + private var fontWeight: Int = ReactConstants.UNSET + private var fontSize: Float = EnrichedConstants.TEXT_DEFAULT_FONT_SIZE + + private var enrichedStyle: EnrichedTextStyle? = null + private val spannableFactory = EnrichedTextSpanFactory() + + constructor(context: Context) : super(context) { + prepareComponent() + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + prepareComponent() + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr, + ) { + prepareComponent() + } + + private fun prepareComponent() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + breakStrategy = LineBreaker.BREAK_STRATEGY_HIGH_QUALITY + } + + movementMethod = EnrichedTextMovementMethod.getInstance() + setPadding(0, 0, 0, 0) + setFontSize(EnrichedConstants.TEXT_DEFAULT_FONT_SIZE) + } + + override fun setTextIsSelectable(selectable: Boolean) { + super.setTextIsSelectable(selectable) + movementMethod = EnrichedTextMovementMethod.getInstance() + } + + private fun updateValue() { + val text = value ?: return + val style = enrichedStyle ?: return + if (!valueDirty) return + + valueDirty = false + val isHtml = text.startsWith("") && text.endsWith("") + if (!isHtml) { + this.text = text + return + } + + try { + val parsed = EnrichedParser.fromHtml(text, style, spannableFactory) + val withoutLastNewLine = parsed.trimEnd('\n') + setText(withoutLastNewLine, BufferType.SPANNABLE) + observeAsyncImages() + } catch (e: Exception) { + this.text = text + } + } + + private fun observeAsyncImages() { + val spannable = text as? Spannable ?: return + val spans = spannable.getSpans(0, spannable.length, EnrichedTextImageSpan::class.java) + for (span in spans) { + span.observeAsyncDrawableLoaded(spannable) + } + } + + private fun updateTypeface() { + if (!typefaceDirty) return + typefaceDirty = false + + val newTypeface = applyStyles(typeface, fontStyle, fontWeight, fontFamily, context.assets) + typeface = newTypeface + paint.typeface = newTypeface + } + + fun setValue(text: String?) { + value = text + valueDirty = true + } + + fun setHtmlStyle(style: ReadableMap?) { + if (style == null) return + + val enrichedStyle = EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize.toInt(), style) + this.enrichedStyle = enrichedStyle + + val currentText = text ?: return + if (currentText.isEmpty()) return + + val spannable = SpannableString(currentText) + val spans = spannable.getSpans(0, spannable.length, EnrichedTextSpan::class.java) + var modified = false + + for (span in spans) { + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + val flags = spannable.getSpanFlags(span) + + if (start == -1 || end == -1) continue + + spannable.removeSpan(span) + val newSpan = span.rebuildWithStyle(enrichedStyle) + spannable.setSpan(newSpan, start, end, flags) + modified = true + } + + if (modified) { + this.text = spannable + } + } + + fun setColor(colorInt: Int?) { + if (colorInt == null) { + setTextColor(Color.BLACK) + return + } + + setTextColor(colorInt) + } + + fun setFontSize(size: Float) { + if (size == 0f) return + + val sizeInt = ceil(PixelUtil.toPixelFromSP(size)) + fontSize = sizeInt + setTextSize(TypedValue.COMPLEX_UNIT_PX, sizeInt) + } + + fun setFontFamily(family: String?) { + if (family != fontFamily) { + fontFamily = family + typefaceDirty = true + } + } + + fun setFontWeight(weight: String?) { + val fontWeight = parseFontWeight(weight) + + if (fontWeight != this.fontWeight) { + this.fontWeight = fontWeight + typefaceDirty = true + } + } + + fun setFontStyle(style: String?) { + val fontStyle = parseFontStyle(style) + + if (fontStyle != this.fontStyle) { + this.fontStyle = fontStyle + typefaceDirty = true + } + } + + fun setSelectionColor(colorInt: Int?) { + if (colorInt == null) return + + highlightColor = colorInt + } + + fun setEllipsizeMode(mode: String?) { + ellipsize = + when (mode) { + "tail" -> TextUtils.TruncateAt.END + "head" -> TextUtils.TruncateAt.START + "middle" -> TextUtils.TruncateAt.MIDDLE + "clip" -> null + else -> TextUtils.TruncateAt.END + } + } + + fun setNumberOfLines(lines: Int) { + maxLines = if (lines == 0) ViewDefaults.NUMBER_OF_LINES else lines + } + + fun afterUpdateTransaction() { + updateTypeface() + updateValue() + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt new file mode 100644 index 000000000..020e763e5 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt @@ -0,0 +1,143 @@ +package com.swmansion.enriched.text + +import android.content.Context +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.EnrichedTextViewManagerDelegate +import com.facebook.react.viewmanagers.EnrichedTextViewManagerInterface +import com.facebook.yoga.YogaMeasureMode +import com.swmansion.enriched.text.events.OnLinkPressEvent +import com.swmansion.enriched.text.events.OnMentionPressEvent + +@ReactModule(name = EnrichedTextViewManager.NAME) +class EnrichedTextViewManager : + SimpleViewManager(), + EnrichedTextViewManagerInterface { + private val mDelegate: ViewManagerDelegate = + EnrichedTextViewManagerDelegate(this) + + override fun getDelegate(): ViewManagerDelegate? = mDelegate + + override fun getName(): String = NAME + + public override fun createViewInstance(context: ThemedReactContext): EnrichedTextView = EnrichedTextView(context) + + override fun getExportedCustomDirectEventTypeConstants(): MutableMap { + val map = mutableMapOf() + map.put(OnLinkPressEvent.EVENT_NAME, mapOf("registrationName" to OnLinkPressEvent.EVENT_NAME)) + map.put(OnMentionPressEvent.EVENT_NAME, mapOf("registrationName" to OnMentionPressEvent.EVENT_NAME)) + return map + } + + override fun setText( + view: EnrichedTextView?, + value: String?, + ) { + view?.setValue(value) + } + + override fun setColor( + view: EnrichedTextView?, + value: Int?, + ) { + view?.setColor(value) + } + + override fun setFontSize( + view: EnrichedTextView?, + value: Float, + ) { + view?.setFontSize(value) + } + + override fun setFontFamily( + view: EnrichedTextView?, + value: String?, + ) { + view?.setFontFamily(value) + } + + override fun setFontWeight( + view: EnrichedTextView?, + value: String?, + ) { + view?.setFontWeight(value) + } + + override fun setFontStyle( + view: EnrichedTextView?, + value: String?, + ) { + view?.setFontStyle(value) + } + + override fun setPadding( + view: EnrichedTextView?, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + super.setPadding(view, left, top, right, bottom) + + view?.setPadding(left, top, right, bottom) + } + + override fun setSelectionColor( + view: EnrichedTextView?, + value: Int?, + ) { + view?.setSelectionColor(value) + } + + override fun setSelectable( + view: EnrichedTextView?, + value: Boolean, + ) { + view?.setTextIsSelectable(value) + } + + override fun setEllipsizeMode( + view: EnrichedTextView?, + value: String?, + ) { + view?.setEllipsizeMode(value) + } + + override fun setNumberOfLines( + view: EnrichedTextView?, + value: Int, + ) { + view?.setNumberOfLines(value) + } + + override fun setHtmlStyle( + view: EnrichedTextView?, + value: ReadableMap?, + ) { + view?.setHtmlStyle(value) + } + + override fun onAfterUpdateTransaction(view: EnrichedTextView) { + view.afterUpdateTransaction() + } + + override fun measure( + context: Context, + localData: ReadableMap?, + props: ReadableMap?, + state: ReadableMap?, + width: Float, + widthMode: YogaMeasureMode?, + height: Float, + heightMode: YogaMeasureMode?, + attachmentsPositions: FloatArray?, + ): Long = MeasurementStore.getMeasureById(context, width, height, heightMode, props) + + companion object { + const val NAME = "EnrichedTextView" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt new file mode 100644 index 000000000..e07e8080a --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt @@ -0,0 +1,166 @@ +package com.swmansion.enriched.text + +import android.content.Context +import android.graphics.Typeface +import android.graphics.text.LineBreaker +import android.os.Build +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.util.Log +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight +import com.facebook.yoga.YogaMeasureMode +import com.facebook.yoga.YogaMeasureOutput +import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.parser.EnrichedParser +import kotlin.math.ceil + +object MeasurementStore { + private fun measure( + maxWidth: Float, + spannable: CharSequence?, + typeface: Typeface, + fontSize: Float, + numberOfLines: Int, + ellipsizeMode: String?, + ): Long { + val text = spannable ?: "" + val textLength = text.length + val paint = + TextPaint().apply { + this.typeface = typeface + textSize = fontSize + } + + val builder = + StaticLayout.Builder + .obtain(text, 0, textLength, paint, maxWidth.toInt()) + .setIncludePad(true) + .setLineSpacing(0f, 1f) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setUseLineSpacingFromFallbacks(true) + } + + if (numberOfLines > 0) { + val ellipsize = + when (ellipsizeMode) { + "head" -> TextUtils.TruncateAt.START + "middle" -> TextUtils.TruncateAt.MIDDLE + "tail" -> TextUtils.TruncateAt.END + "clip" -> null + else -> null + } + + builder.setMaxLines(numberOfLines).setEllipsize(ellipsize) + } + + val staticLayout = builder.build() + + // Workaround for Android issue where maxLines >= 2 and ellipsize != TruncateAt.END + // In such scenario, StaticLayout always returns lineCount = maxLines even if text fits in less lines + val actualLineCount = + if (numberOfLines > 0) { + staticLayout.lineCount.coerceAtMost(numberOfLines) + } else { + staticLayout.lineCount + } + + // For one line text, use exact line width + // For multi line, use all available width + val finalWidth = + if (staticLayout.lineCount <= 1) { + staticLayout.getLineWidth(0) + } else { + staticLayout.width.toFloat() + } + + val finalHeight = + if (actualLineCount > 0) { + staticLayout.getLineBottom(actualLineCount - 1).toFloat() + } else { + 0f + } + + val heightInSP = PixelUtil.toDIPFromPixel(finalHeight) + val widthInSP = PixelUtil.toDIPFromPixel(finalWidth) + return YogaMeasureOutput.make(widthInSP, heightInSP) + } + + private fun getInitialText( + context: Context, + fontSize: Int, + props: ReadableMap?, + ): CharSequence { + val text = props?.getString("text") ?: "" + + val isHtml = text.startsWith("") && text.endsWith("") + if (!isHtml) return text + + try { + val style = props?.getMap("htmlStyle") ?: return text + val enrichedStyle = EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize, style) + val factory = EnrichedTextSpanFactory() + val parsed = EnrichedParser.fromHtml(text, enrichedStyle, factory) + return parsed.trimEnd('\n') + } catch (e: Exception) { + Log.w("MeasurementStore", "Error parsing initial HTML text: ${e.message}") + return text + } + } + + private fun getInitialFontSize(props: ReadableMap?): Float { + val propsFontSize = props?.getDouble("fontSize")?.toFloat() ?: EnrichedConstants.TEXT_DEFAULT_FONT_SIZE + val fontSize = + when { + propsFontSize > 0f -> propsFontSize + else -> EnrichedConstants.TEXT_DEFAULT_FONT_SIZE + } + + return ceil(PixelUtil.toPixelFromSP(fontSize)) + } + + private fun getMeasureById( + context: Context, + width: Float, + props: ReadableMap?, + ): Long { + val fontSize = getInitialFontSize(props) + val text = getInitialText(context, fontSize.toInt(), props) + + val fontFamily = props?.getString("fontFamily") + val numberOfLines = props?.getInt("numberOfLines") ?: 0 + val ellipsizeMode = props?.getString("ellipsizeMode") + val fontStyle = parseFontStyle(props?.getString("fontStyle")) + val fontWeight = parseFontWeight(props?.getString("fontWeight")) + val typeface = applyStyles(null, fontStyle, fontWeight, fontFamily, context.assets) + val size = measure(width, text, typeface, fontSize, numberOfLines, ellipsizeMode) + + return size + } + + fun getMeasureById( + context: Context, + width: Float, + height: Float, + heightMode: YogaMeasureMode?, + props: ReadableMap?, + ): Long { + val size = getMeasureById(context, width, props) + if (heightMode !== YogaMeasureMode.AT_MOST) return size + + val calculatedHeight = YogaMeasureOutput.getHeight(size) + val atMostHeight = PixelUtil.toDIPFromPixel(height) + val finalHeight = calculatedHeight.coerceAtMost(atMostHeight) + return YogaMeasureOutput.make(YogaMeasureOutput.getWidth(size), finalHeight) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/events/OnLinkPressEvent.kt b/android/src/main/java/com/swmansion/enriched/text/events/OnLinkPressEvent.kt new file mode 100644 index 000000000..af1a3141d --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/events/OnLinkPressEvent.kt @@ -0,0 +1,23 @@ +package com.swmansion.enriched.text.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnLinkPressEvent( + surfaceId: Int, + viewId: Int, + private val url: String, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap? { + val eventData: WritableMap = Arguments.createMap() + eventData.putString("url", url) + return eventData + } + + companion object { + const val EVENT_NAME: String = "onLinkPress" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/events/OnMentionPressEvent.kt b/android/src/main/java/com/swmansion/enriched/text/events/OnMentionPressEvent.kt new file mode 100644 index 000000000..c2395672c --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/events/OnMentionPressEvent.kt @@ -0,0 +1,32 @@ +package com.swmansion.enriched.text.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnMentionPressEvent( + surfaceId: Int, + viewId: Int, + private val text: String, + private val indicator: String, + private val attributes: Map, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap? { + val eventData: WritableMap = Arguments.createMap() + val attrsMap = Arguments.createMap() + for ((key, value) in attributes) { + attrsMap.putString(key, value) + } + + eventData.putString("text", text) + eventData.putMap("attributes", attrsMap) + eventData.putString("indicator", indicator) + return eventData + } + + companion object { + const val EVENT_NAME: String = "onMentionPress" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextBlockQuoteSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextBlockQuoteSpan.kt new file mode 100644 index 000000000..1f90c1357 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextBlockQuoteSpan.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedBlockQuoteSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextBlockQuoteSpan( + enrichedStyle: EnrichedTextStyle, +) : EnrichedBlockQuoteSpan(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextBlockQuoteSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextBoldSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextBoldSpan.kt new file mode 100644 index 000000000..9d35a1212 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextBoldSpan.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedBoldSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextBoldSpan( + enrichedStyle: EnrichedTextStyle, +) : EnrichedBoldSpan(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = false + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextBoldSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCheckboxListSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCheckboxListSpan.kt new file mode 100644 index 000000000..878bdf9c1 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCheckboxListSpan.kt @@ -0,0 +1,15 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextCheckboxListSpan( + override var isChecked: Boolean, + enrichedStyle: EnrichedTextStyle, +) : EnrichedCheckboxListSpan(isChecked, enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle: Boolean = true + + override fun rebuildWithStyle(style: EnrichedTextStyle): EnrichedTextCheckboxListSpan = EnrichedTextCheckboxListSpan(isChecked, style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCodeBlockSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCodeBlockSpan.kt new file mode 100644 index 000000000..0001c50fe --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCodeBlockSpan.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedCodeBlockSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextCodeBlockSpan( + enrichedStyle: EnrichedTextStyle, +) : EnrichedCodeBlockSpan(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextCodeBlockSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH1Span.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH1Span.kt new file mode 100644 index 000000000..251cb4957 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH1Span.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedH1Span +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextH1Span( + enrichedStyle: EnrichedTextStyle, +) : EnrichedH1Span(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextH1Span(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH2Span.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH2Span.kt new file mode 100644 index 000000000..81fc550f7 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH2Span.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedH2Span +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextH2Span( + enrichedStyle: EnrichedTextStyle, +) : EnrichedH2Span(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextH2Span(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH3Span.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH3Span.kt new file mode 100644 index 000000000..d32c58d2a --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH3Span.kt @@ -0,0 +1,15 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.EnrichedStyle +import com.swmansion.enriched.common.spans.EnrichedH3Span +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextH3Span( + enrichedStyle: EnrichedStyle, +) : EnrichedH3Span(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextH3Span(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH4Span.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH4Span.kt new file mode 100644 index 000000000..cef18f643 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH4Span.kt @@ -0,0 +1,15 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.EnrichedStyle +import com.swmansion.enriched.common.spans.EnrichedH4Span +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextH4Span( + enrichedStyle: EnrichedStyle, +) : EnrichedH4Span(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextH4Span(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH5Span.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH5Span.kt new file mode 100644 index 000000000..63d50335b --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH5Span.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedH5Span +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextH5Span( + enrichedStyle: EnrichedTextStyle, +) : EnrichedH5Span(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextH5Span(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH6Span.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH6Span.kt new file mode 100644 index 000000000..d8150bfa3 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextH6Span.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedH6Span +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextH6Span( + enrichedStyle: EnrichedTextStyle, +) : EnrichedH6Span(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextH6Span(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextImageSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextImageSpan.kt new file mode 100644 index 000000000..6af135e37 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextImageSpan.kt @@ -0,0 +1,36 @@ +package com.swmansion.enriched.text.spans + +import android.graphics.drawable.Drawable +import com.swmansion.enriched.R +import com.swmansion.enriched.common.ResourceManager +import com.swmansion.enriched.common.spans.EnrichedImageSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextImageSpan( + drawable: Drawable, + source: String, + width: Int, + height: Int, +) : EnrichedImageSpan(drawable, source, width, height), + EnrichedTextSpan { + override val dependsOnHtmlStyle = false + + override fun rebuildWithStyle(style: EnrichedTextStyle) = this + + companion object { + fun createEnrichedImageSpan( + src: String, + width: Int, + height: Int, + ): EnrichedImageSpan { + var imgDrawable = prepareDrawableForImage(src, width, height) + + if (imgDrawable == null) { + imgDrawable = ResourceManager.getDrawableResource(R.drawable.broken_image) + } + + return EnrichedTextImageSpan(imgDrawable, src, width, height) + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextInlineCodeSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextInlineCodeSpan.kt new file mode 100644 index 000000000..965c9ec4a --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextInlineCodeSpan.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedInlineCodeSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextInlineCodeSpan( + enrichedStyle: EnrichedTextStyle, +) : EnrichedInlineCodeSpan(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextInlineCodeSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextItalicSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextItalicSpan.kt new file mode 100644 index 000000000..47fcbed9f --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextItalicSpan.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedItalicSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextItalicSpan( + enrichedStyle: EnrichedTextStyle, +) : EnrichedItalicSpan(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = false + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextItalicSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextLinkSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextLinkSpan.kt new file mode 100644 index 000000000..f289eb5f1 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextLinkSpan.kt @@ -0,0 +1,41 @@ +package com.swmansion.enriched.text.spans + +import android.text.TextPaint +import android.view.View +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.swmansion.enriched.common.spans.EnrichedLinkSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.events.OnLinkPressEvent +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextClickableSpan +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextLinkSpan( + private val url: String, + private val enrichedStyle: EnrichedTextStyle, +) : EnrichedLinkSpan(url, enrichedStyle, true), + EnrichedTextSpan, + EnrichedTextClickableSpan { + override val dependsOnHtmlStyle = true + override var isPressed = false + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextLinkSpan(url, style) + + override fun updateDrawState(textPaint: TextPaint) { + super.updateDrawState(textPaint) + textPaint.color = if (isPressed) enrichedStyle.aPressColor else enrichedStyle.aColor + } + + override fun onClick(view: View) { + val context = view.context as? ReactContext ?: return + val surfaceId = UIManagerHelper.getSurfaceId(context) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + dispatcher?.dispatchEvent( + OnLinkPressEvent( + surfaceId, + view.id, + url, + ), + ) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextMentionSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextMentionSpan.kt new file mode 100644 index 000000000..9d49d988e --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextMentionSpan.kt @@ -0,0 +1,63 @@ +package com.swmansion.enriched.text.spans + +import android.text.TextPaint +import android.view.View +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.swmansion.enriched.common.EnrichedStyle +import com.swmansion.enriched.common.spans.EnrichedMentionSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.events.OnMentionPressEvent +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextClickableSpan +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextMentionSpan( + private val text: String, + private val indicator: String, + private val attributes: Map, + private val enrichedStyle: EnrichedStyle, +) : EnrichedMentionSpan(text, indicator, attributes, enrichedStyle), + EnrichedTextSpan, + EnrichedTextClickableSpan { + override val dependsOnHtmlStyle = true + override var isPressed = false + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextMentionSpan(text, indicator, attributes, style) + + override fun updateDrawState(textPaint: TextPaint) { + super.updateDrawState(textPaint) + + val mentionsStyle = enrichedStyle.mentionsStyle[indicator] ?: return + val color = + if (isPressed && mentionsStyle.pressColor != null) { + mentionsStyle.pressColor + } else { + mentionsStyle.color + } + + val bgColor = + if (isPressed && mentionsStyle.pressBackgroundColor != null) { + mentionsStyle.pressBackgroundColor + } else { + mentionsStyle.backgroundColor + } + + textPaint.color = color + textPaint.bgColor = bgColor + } + + override fun onClick(view: View) { + val context = view.context as? ReactContext ?: return + val surfaceId = UIManagerHelper.getSurfaceId(context) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + dispatcher?.dispatchEvent( + OnMentionPressEvent( + surfaceId, + view.id, + text, + indicator, + attributes, + ), + ) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextOrderedListSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextOrderedListSpan.kt new file mode 100644 index 000000000..95847b878 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextOrderedListSpan.kt @@ -0,0 +1,16 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.EnrichedStyle +import com.swmansion.enriched.common.spans.EnrichedOrderedListSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextOrderedListSpan( + index: Int, + enrichedStyle: EnrichedStyle, +) : EnrichedOrderedListSpan(index, enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextOrderedListSpan(index, style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextStrikeThroughSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextStrikeThroughSpan.kt new file mode 100644 index 000000000..743a69127 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextStrikeThroughSpan.kt @@ -0,0 +1,15 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.EnrichedStyle +import com.swmansion.enriched.common.spans.EnrichedStrikeThroughSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextStrikeThroughSpan( + enrichedStyle: EnrichedStyle, +) : EnrichedStrikeThroughSpan(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = false + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextStrikeThroughSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextUnderlineSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextUnderlineSpan.kt new file mode 100644 index 000000000..2ef88c186 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextUnderlineSpan.kt @@ -0,0 +1,15 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.EnrichedStyle +import com.swmansion.enriched.common.spans.EnrichedUnderlineSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextUnderlineSpan( + enrichedStyle: EnrichedStyle, +) : EnrichedUnderlineSpan(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = false + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextUnderlineSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextUnorderedListSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextUnorderedListSpan.kt new file mode 100644 index 000000000..6108b96fc --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextUnorderedListSpan.kt @@ -0,0 +1,15 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.EnrichedStyle +import com.swmansion.enriched.common.spans.EnrichedUnorderedListSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextUnorderedListSpan( + enrichedStyle: EnrichedStyle, +) : EnrichedUnorderedListSpan(enrichedStyle), + EnrichedTextSpan { + override val dependsOnHtmlStyle = true + + override fun rebuildWithStyle(style: EnrichedTextStyle) = EnrichedTextUnorderedListSpan(style) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/interfaces/EnrichedTextClickableSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/interfaces/EnrichedTextClickableSpan.kt new file mode 100644 index 000000000..9c42d4dee --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/interfaces/EnrichedTextClickableSpan.kt @@ -0,0 +1,9 @@ +package com.swmansion.enriched.text.spans.interfaces + +import android.view.View + +interface EnrichedTextClickableSpan { + var isPressed: Boolean + + fun onClick(view: View) +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/interfaces/EnrichedTextSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/interfaces/EnrichedTextSpan.kt new file mode 100644 index 000000000..caeeaa9e7 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/interfaces/EnrichedTextSpan.kt @@ -0,0 +1,10 @@ +package com.swmansion.enriched.text.spans.interfaces + +import com.swmansion.enriched.common.spans.interfaces.EnrichedSpan +import com.swmansion.enriched.text.EnrichedTextStyle + +interface EnrichedTextSpan : EnrichedSpan { + val dependsOnHtmlStyle: Boolean + + fun rebuildWithStyle(style: EnrichedTextStyle): EnrichedTextSpan +} diff --git a/android/src/main/new_arch/ReactNativeEnrichedSpec.h b/android/src/main/new_arch/ReactNativeEnrichedSpec.h index 9e6642ac8..944c422ee 100644 --- a/android/src/main/new_arch/ReactNativeEnrichedSpec.h +++ b/android/src/main/new_arch/ReactNativeEnrichedSpec.h @@ -4,6 +4,7 @@ #include #include +#include #include namespace facebook::react { diff --git a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextComponentDescriptor.h b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextComponentDescriptor.h new file mode 100644 index 000000000..03312a4c3 --- /dev/null +++ b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextComponentDescriptor.h @@ -0,0 +1,32 @@ +#pragma once + +#include "EnrichedTextMeasurementManager.h" +#include "EnrichedTextShadowNode.h" + +#include + +namespace facebook::react { + +class EnrichedTextComponentDescriptor final + : public ConcreteComponentDescriptor { +public: + EnrichedTextComponentDescriptor( + const ComponentDescriptorParameters ¶meters) + : ConcreteComponentDescriptor(parameters), + measurementsManager_(std::make_shared( + contextContainer_)) {} + + void adopt(ShadowNode &shadowNode) const override { + ConcreteComponentDescriptor::adopt(shadowNode); + auto &editorShadowNode = static_cast(shadowNode); + + // `EnrichedTextShadowNode` uses + // `EnrichedTextMeasurementManager` to provide measurements to Yoga. + editorShadowNode.setMeasurementsManager(measurementsManager_); + } + +private: + const std::shared_ptr measurementsManager_; +}; + +} // namespace facebook::react diff --git a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextMeasurementManager.cpp b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextMeasurementManager.cpp new file mode 100644 index 000000000..cd8a9e192 --- /dev/null +++ b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextMeasurementManager.cpp @@ -0,0 +1,45 @@ +#include "EnrichedTextMeasurementManager.h" +#include "conversions.h" + +#include +#include +#include + +using namespace facebook::jni; + +namespace facebook::react { + +Size EnrichedTextMeasurementManager::measure( + SurfaceId surfaceId, int viewTag, const EnrichedTextViewProps &props, + LayoutConstraints layoutConstraints) const { + const jni::global_ref &fabricUIManager = + contextContainer_->at>("FabricUIManager"); + + static const auto measure = + facebook::jni::findClassStatic( + "com/facebook/react/fabric/FabricUIManager") + ->getMethod("measure"); + + auto minimumSize = layoutConstraints.minimumSize; + auto maximumSize = layoutConstraints.maximumSize; + + local_ref componentName = make_jstring("EnrichedTextView"); + + // Prepare layout metrics affecting props + auto serializedProps = toDynamic(props); + local_ref propsRNM = + ReadableNativeMap::newObjectCxxArgs(serializedProps); + local_ref propsRM = + make_local(reinterpret_cast(propsRNM.get())); + + auto measurement = yogaMeassureToSize( + measure(fabricUIManager, surfaceId, componentName.get(), nullptr, + propsRM.get(), nullptr, minimumSize.width, maximumSize.width, + minimumSize.height, maximumSize.height)); + + return measurement; +} + +} // namespace facebook::react diff --git a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextMeasurementManager.h b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextMeasurementManager.h new file mode 100644 index 000000000..080ae47bf --- /dev/null +++ b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextMeasurementManager.h @@ -0,0 +1,25 @@ +#pragma once + +#include "ComponentDescriptors.h" + +#include +#include +#include + +namespace facebook::react { + +class EnrichedTextMeasurementManager { +public: + EnrichedTextMeasurementManager( + const std::shared_ptr &contextContainer) + : contextContainer_(contextContainer) {} + + Size measure(SurfaceId surfaceId, int viewTag, + const EnrichedTextViewProps &props, + LayoutConstraints layoutConstraints) const; + +private: + const std::shared_ptr contextContainer_; +}; + +} // namespace facebook::react diff --git a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextShadowNode.cpp b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextShadowNode.cpp new file mode 100644 index 000000000..8ac906ce5 --- /dev/null +++ b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextShadowNode.cpp @@ -0,0 +1,21 @@ +#include "EnrichedTextShadowNode.h" + +#include + +namespace facebook::react { +extern const char EnrichedTextComponentName[] = "EnrichedTextView"; +void EnrichedTextShadowNode::setMeasurementsManager( + const std::shared_ptr + &measurementsManager) { + ensureUnsealed(); + measurementsManager_ = measurementsManager; +} + +Size EnrichedTextShadowNode::measureContent( + const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + return measurementsManager_->measure(getSurfaceId(), getTag(), + getConcreteProps(), layoutConstraints); +} + +} // namespace facebook::react diff --git a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextShadowNode.h b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextShadowNode.h new file mode 100644 index 000000000..cdefd1686 --- /dev/null +++ b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/EnrichedTextShadowNode.h @@ -0,0 +1,39 @@ +#pragma once + +#include "EnrichedTextMeasurementManager.h" + +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char EnrichedTextComponentName[]; + +class EnrichedTextShadowNode final + : public ConcreteViewShadowNode { +public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + static ShadowNodeTraits BaseTraits() { + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + + // Associates a shared `EnrichedTextMeasurementManager` with the node. + void + setMeasurementsManager(const std::shared_ptr + &measurementsManager); + + Size + measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const override; + +private: + std::shared_ptr measurementsManager_; +}; +} // namespace facebook::react diff --git a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/conversions.h b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/conversions.h index 36544d89a..f77dd91ec 100644 --- a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/conversions.h +++ b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/conversions.h @@ -24,4 +24,19 @@ inline folly::dynamic toDynamic(const EnrichedTextInputViewProps &props) { } #endif +inline folly::dynamic toDynamic(const EnrichedTextViewProps &props) { + // Serialize only metrics affecting props + folly::dynamic serializedProps = folly::dynamic::object(); + serializedProps["text"] = props.text; + serializedProps["fontSize"] = props.fontSize; + serializedProps["fontWeight"] = props.fontWeight; + serializedProps["fontStyle"] = props.fontStyle; + serializedProps["fontFamily"] = props.fontFamily; + serializedProps["numberOfLines"] = props.numberOfLines; + serializedProps["ellipsizeMode"] = props.ellipsizeMode; + serializedProps["htmlStyle"] = toDynamic(props.htmlStyle); + + return serializedProps; +} + } // namespace facebook::react diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 8c0c1f12b..6c2d54105 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -1,13 +1,25 @@ import { useState } from 'react'; import { DevScreen } from './screens/DevScreen'; import { TestScreen } from './screens/TestScreen'; +import { EnrichedTextScreen } from './screens/EnrichedTextScreen'; + +type Screen = 'dev' | 'test' | 'enrichedText'; export default function App() { - const [isTestScreen, setIsTestScreen] = useState(false); + const [screen, setScreen] = useState('dev'); + + if (screen === 'test') { + return ( + setScreen('dev')} + onSwitchEnrichedText={() => setScreen('enrichedText')} + /> + ); + } - if (isTestScreen) { - return setIsTestScreen(false)} />; + if (screen === 'enrichedText') { + return setScreen('test')} />; } - return setIsTestScreen(true)} />; + return setScreen('test')} />; } diff --git a/apps/example/src/components/TextRenderer.tsx b/apps/example/src/components/TextRenderer.tsx new file mode 100644 index 000000000..2cef09cc0 --- /dev/null +++ b/apps/example/src/components/TextRenderer.tsx @@ -0,0 +1,60 @@ +import { Alert, StyleSheet, View } from 'react-native'; +import { + EnrichedText, + type OnLinkPressEvent, + type OnMentionPressEvent, +} from 'react-native-enriched'; +import { enrichedTextHtmlStyle } from '../constants/htmlStyle'; + +interface TextRendererProps { + nodes: Array; +} + +export const TextRenderer = ({ nodes }: TextRendererProps) => { + const handleLinkPress = (e: OnLinkPressEvent) => { + Alert.alert('Link Pressed', `You pressed the link: ${e.url}`); + }; + + const handleMentionPress = (e: OnMentionPressEvent) => { + Alert.alert( + 'Mention Pressed', + `You pressed the mention: text: ${e.text}, type: ${e.indicator}, attributes: ${JSON.stringify(e.attributes)}` + ); + }; + + if (nodes.length === 0) { + return null; + } + + return ( + + {nodes.map((node, index) => ( + + {node} + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + padding: 16, + borderWidth: StyleSheet.hairlineWidth, + marginVertical: 16, + borderRadius: 8, + }, + text: { + fontSize: 16, + color: 'black', + marginTop: 4, + fontFamily: 'Nunito-Regular', + }, +}); diff --git a/apps/example/src/constants/htmlStyle.ts b/apps/example/src/constants/htmlStyle.ts new file mode 100644 index 000000000..7d8adddcd --- /dev/null +++ b/apps/example/src/constants/htmlStyle.ts @@ -0,0 +1,97 @@ +import type { EnrichedTextHtmlStyle, HtmlStyle } from 'react-native-enriched'; + +export const htmlStyle = { + h1: { + fontSize: 72, + bold: true, + }, + h2: { + fontSize: 60, + bold: true, + }, + h3: { + fontSize: 50, + bold: true, + }, + h4: { + fontSize: 40, + bold: true, + }, + h5: { + fontSize: 30, + bold: true, + }, + h6: { + fontSize: 24, + bold: true, + }, + blockquote: { + borderColor: 'navy', + borderWidth: 4, + gapWidth: 16, + color: 'navy', + }, + codeblock: { + color: 'green', + borderRadius: 8, + backgroundColor: 'aquamarine', + }, + code: { + color: 'purple', + backgroundColor: 'yellow', + }, + a: { + color: 'green', + textDecorationLine: 'underline', + }, + mention: { + '#': { + color: 'blue', + backgroundColor: 'lightblue', + textDecorationLine: 'underline', + }, + '@': { + color: 'green', + backgroundColor: 'lightgreen', + textDecorationLine: 'none', + }, + }, + ol: { + gapWidth: 16, + marginLeft: 24, + markerColor: 'navy', + markerFontWeight: 'bold', + }, + ul: { + bulletColor: 'aquamarine', + bulletSize: 8, + marginLeft: 24, + gapWidth: 16, + }, + ulCheckbox: { + boxSize: 24, + gapWidth: 16, + marginLeft: 24, + boxColor: 'rgb(0, 26, 114)', + }, +} satisfies HtmlStyle; + +export const enrichedTextHtmlStyle: EnrichedTextHtmlStyle = { + ...htmlStyle, + a: { + ...htmlStyle.a, + pressColor: 'darkgreen', + }, + mention: { + '#': { + ...htmlStyle.mention['#'], + pressColor: 'darkgreen', + pressBackgroundColor: 'lightgreen', + }, + '@': { + ...htmlStyle.mention['@'], + pressColor: 'darkblue', + pressBackgroundColor: 'blue', + }, + }, +}; diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index 7450fe8e3..04c8d03ed 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -12,8 +12,9 @@ import { LINK_REGEX, htmlStyle, ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS, - DEBUG_SCROLLABLE, } from '../constants/editorConfig'; +import { useState } from 'react'; +import { TextRenderer } from '../components/TextRenderer'; interface DevScreenProps { onSwitch: () => void; @@ -21,6 +22,16 @@ interface DevScreenProps { export function DevScreen({ onSwitch }: DevScreenProps) { const editor = useEditorState(); + const [textNodes, setTextNodes] = useState>([]); + + const handlePushTextNode = async () => { + const currentText = await editor.ref.current?.getHTML(); + if (currentText) { + setTextNodes((prevTextNodes) => [...prevTextNodes, currentText]); + } + + editor.ref.current?.setValue(''); + }; return ( <> @@ -73,34 +84,26 @@ export function DevScreen({ onSwitch }: DevScreenProps) { layout="horizontal" /> +