diff --git a/.maestro/enrichedInput/flows/paragraph_styles_alignment_visual.yaml b/.maestro/enrichedInput/flows/paragraph_styles_alignment_visual.yaml index a6890402d..ae5d09c57 100644 --- a/.maestro/enrichedInput/flows/paragraph_styles_alignment_visual.yaml +++ b/.maestro/enrichedInput/flows/paragraph_styles_alignment_visual.yaml @@ -1,6 +1,4 @@ appId: swmansion.enriched.example -tags: - - ios-only --- # Visually validates text alignment rendering - launchApp diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_alignment.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_alignment.png new file mode 100644 index 000000000..31828ca25 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_alignment.png differ diff --git a/.maestro/enrichedText/screenshots/android/alignment_visual.png b/.maestro/enrichedText/screenshots/android/alignment_visual.png new file mode 100644 index 000000000..c3e03756a Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/alignment_visual.png differ diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java index 054b89c12..8ab430a52 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java @@ -1,14 +1,13 @@ package com.swmansion.enriched.common.parser; import android.text.Editable; -import android.text.Layout; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; -import android.text.style.AlignmentSpan; import android.text.style.ParagraphStyle; import com.swmansion.enriched.common.EnrichedConstants; +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan; import com.swmansion.enriched.common.spans.EnrichedBoldSpan; import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan; import com.swmansion.enriched.common.spans.EnrichedCodeBlockSpan; @@ -35,6 +34,8 @@ import java.io.StringReader; import java.util.HashMap; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.ccil.cowan.tagsoup.HTMLSchema; import org.ccil.cowan.tagsoup.Parser; import org.xml.sax.Attributes; @@ -86,8 +87,8 @@ public static String toHtml(Spanned text) { String normalizedBlockQuote = normalizedCodeBlock.replaceAll("\\n
", ""); - // replace empty

into
in the very end - String normalizedHtml = normalizedBlockQuote.replaceAll("

", "
"); + // Replace empty

tags (with or without style attributes) with
+ String normalizedHtml = normalizedBlockQuote.replaceAll("]*>

", "
"); return "\n" + normalizedHtml + ""; } @@ -136,6 +137,14 @@ private static void withinDiv(StringBuilder out, Spanned text, int start, int en } } + private static String getAlignmentStyleAttr(Spanned text, int start, int end) { + EnrichedAlignmentSpan[] spans = text.getSpans(start, end, EnrichedAlignmentSpan.class); + if (spans.length == 0) return ""; + String cssValue = spans[0].getCssValue(); + if (cssValue.equals("auto")) return ""; + return " style=\"text-align: " + cssValue + "\""; + } + private static String getBlockTag(EnrichedParagraphSpan[] spans) { for (EnrichedParagraphSpan span : spans) { if (span instanceof EnrichedUnorderedListSpan) { @@ -213,15 +222,17 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int if (isUlListItem && !isInUlList) { // Current paragraph is the first item in a list isInUlList = true; - out.append("\n"); + out.append("\n"); } else if (isOlListItem && !isInOlList) { // Current paragraph is the first item in a list isInOlList = true; - out.append("\n"); + out.append("\n"); } else if (isCheckboxListItem && !isInCheckboxList) { // Current paragraph is the first item in a list isInCheckboxList = true; - out.append("
    \n"); + out.append("
      \n"); } boolean isList = isUlListItem || isOlListItem || isCheckboxListItem; @@ -230,6 +241,11 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int out.append("<"); out.append(tagType); + // Add alignment style to non-list paragraph/heading tags + if (!isList) { + out.append(getAlignmentStyleAttr(text, i, next)); + } + if (isCheckboxListItem) { EnrichedCheckboxListSpan[] checkboxSpans = text.getSpans(i, next, EnrichedCheckboxListSpan.class); @@ -395,6 +411,24 @@ class HtmlToSpannedConverter implements ContentHandler { private static Boolean isInOrderedList = false; private static Boolean isInCheckboxList = false; private static Boolean isEmptyTag = false; + private static String currentListAlignmentCssValue = null; + + private static final Pattern CSS_ALIGNMENT_PATTERN = + Pattern.compile("text-align\\s*:\\s*(left|center|right)", Pattern.CASE_INSENSITIVE); + + private static String parseCssAlignmentValue(Attributes attributes) { + String style = attributes.getValue("", "style"); + if (style == null) return null; + Matcher m = CSS_ALIGNMENT_PATTERN.matcher(style); + return m.find() ? m.group(1).toLowerCase() : null; + } + + private static void pushAlignmentMark(Editable text, Attributes attributes) { + String cssValue = parseCssAlignmentValue(attributes); + if (cssValue != null) { + start(text, new Alignment(cssValue)); + } + } public HtmlToSpannedConverter( String source, T style, Parser parser, EnrichedSpanFactory spanFactory) { @@ -448,9 +482,23 @@ public Spanned convert() { int end = mSpannableStringBuilder.getSpanEnd(zeroWidthSpaceSpan); if (mSpannableStringBuilder.charAt(start) != EnrichedConstants.ZWS) { - // Insert zero-width space character at the start if it's not already present. + // Collect spans before inserting ZWS. SPAN_EXCLUSIVE_EXCLUSIVE spans will + // shift to start+1. We must re-anchor them back to `start` to prevent + // the loop from processing them again and inserting duplicate ZWS. + Object[] colocated = mSpannableStringBuilder.getSpans(start, start + 1, Object.class); + mSpannableStringBuilder.insert(start, EnrichedConstants.ZWS_STRING); - end++; // Adjust end position due to insertion. + end++; + + for (Object span : colocated) { + if (span == zeroWidthSpaceSpan) continue; + // Only re-anchor spans that actually shifted. + // Skip overlapping or INCLUSIVE spans that kept their original start. + if (mSpannableStringBuilder.getSpanStart(span) != start + 1) continue; + int spanEnd = mSpannableStringBuilder.getSpanEnd(span); + mSpannableStringBuilder.removeSpan(span); + mSpannableStringBuilder.setSpan(span, start, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } } mSpannableStringBuilder.removeSpan(zeroWidthSpaceSpan); @@ -468,14 +516,17 @@ private void handleStartTag(String tag, Attributes attributes) { } else if (tag.equalsIgnoreCase("p")) { isEmptyTag = true; startBlockElement(mSpannableStringBuilder); + pushAlignmentMark(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("ul")) { isInOrderedList = false; String dataType = attributes.getValue("", "data-type"); isInCheckboxList = "checkbox".equals(dataType); + currentListAlignmentCssValue = parseCssAlignmentValue(attributes); startBlockElement(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("ol")) { isInOrderedList = true; currentOrderedListItemIndex = 0; + currentListAlignmentCssValue = parseCssAlignmentValue(attributes); startBlockElement(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("li")) { isEmptyTag = true; @@ -500,16 +551,22 @@ private void handleStartTag(String tag, Attributes attributes) { start(mSpannableStringBuilder, new Strikethrough()); } else if (tag.equalsIgnoreCase("h1")) { startHeading(mSpannableStringBuilder, 1); + pushAlignmentMark(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("h2")) { startHeading(mSpannableStringBuilder, 2); + pushAlignmentMark(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("h3")) { startHeading(mSpannableStringBuilder, 3); + pushAlignmentMark(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("h4")) { startHeading(mSpannableStringBuilder, 4); + pushAlignmentMark(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("h5")) { startHeading(mSpannableStringBuilder, 5); + pushAlignmentMark(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("h6")) { startHeading(mSpannableStringBuilder, 6); + pushAlignmentMark(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("img")) { // Image content means the current tag is not empty (e.g.
    • ). isEmptyTag = false; @@ -525,9 +582,13 @@ private void handleEndTag(String tag) { if (tag.equalsIgnoreCase("br")) { handleBr(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("p")) { - endBlockElement(mSpannableStringBuilder); + endBlockElement(mSpannableStringBuilder, mSpanFactory); } else if (tag.equalsIgnoreCase("ul")) { - endBlockElement(mSpannableStringBuilder); + currentListAlignmentCssValue = null; + endBlockElement(mSpannableStringBuilder, mSpanFactory); + } else if (tag.equalsIgnoreCase("ol")) { + currentListAlignmentCssValue = null; + endBlockElement(mSpannableStringBuilder, mSpanFactory); } else if (tag.equalsIgnoreCase("li")) { endLi(mSpannableStringBuilder, mStyle, mSpanFactory); } else if (tag.equalsIgnoreCase("b")) { @@ -585,7 +646,7 @@ private static void startBlockElement(Editable text) { start(text, new Newline(1)); } - private static void endBlockElement(Editable text) { + private static void endBlockElement(Editable text, EnrichedSpanFactory spanFactory) { Newline n = getLast(text, Newline.class); if (n != null) { appendNewlines(text, n.mNumNewlines); @@ -593,7 +654,7 @@ private static void endBlockElement(Editable text) { } Alignment a = getLast(text, Alignment.class); if (a != null) { - setSpanFromMark(text, a, new AlignmentSpan.Standard(a.mAlignment)); + setParagraphSpanFromMark(text, a, spanFactory.createAlignmentSpan(a.mCssValue)); } } @@ -604,6 +665,10 @@ private static void handleBr(Editable text) { private void startLi(Editable text, Attributes attributes) { startBlockElement(text); + if (currentListAlignmentCssValue != null) { + start(text, new Alignment(currentListAlignmentCssValue)); + } + if (isInOrderedList) { currentOrderedListItemIndex++; start(text, new List("ordered", currentOrderedListItemIndex, false)); @@ -616,7 +681,7 @@ private void startLi(Editable text, Attributes attributes) { } private static void endLi(Editable text, T style, EnrichedSpanFactory spanFactory) { - endBlockElement(text); + endBlockElement(text, spanFactory); List l = getLast(text, List.class); if (l != null) { @@ -629,7 +694,7 @@ private static void endLi(Editable text, T style, EnrichedSpanFactory spa } } - endBlockElement(text); + endBlockElement(text, spanFactory); } private void startBlockquote(Editable text) { @@ -639,7 +704,7 @@ private void startBlockquote(Editable text) { private static void endBlockquote( Editable text, T style, EnrichedSpanFactory spanFactory) { - endBlockElement(text); + endBlockElement(text, spanFactory); Blockquote last = getLast(text, Blockquote.class); setParagraphSpanFromMark(text, last, spanFactory.createBlockQuoteSpan(style)); } @@ -650,7 +715,7 @@ private void startCodeBlock(Editable text) { } private static void endCodeBlock(Editable text, T style, EnrichedSpanFactory spanFactory) { - endBlockElement(text); + endBlockElement(text, spanFactory); CodeBlock last = getLast(text, CodeBlock.class); setParagraphSpanFromMark(text, last, spanFactory.createCodeBlockSpan(style)); } @@ -684,7 +749,7 @@ private void startHeading(Editable text, int level) { private static void endHeading( Editable text, T style, EnrichedSpanFactory spanFactory, int level) { - endBlockElement(text); + endBlockElement(text, spanFactory); switch (level) { case 1: @@ -958,10 +1023,10 @@ public Newline(int numNewlines) { } private static class Alignment { - private final Layout.Alignment mAlignment; + final String mCssValue; - public Alignment(Layout.Alignment alignment) { - mAlignment = alignment; + public Alignment(String cssValue) { + this.mCssValue = cssValue; } } } diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt index 31d1a7049..f61ef8d60 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt @@ -1,5 +1,6 @@ package com.swmansion.enriched.common.parser +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan import com.swmansion.enriched.common.spans.EnrichedBlockQuoteSpan import com.swmansion.enriched.common.spans.EnrichedBoldSpan import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan @@ -21,6 +22,8 @@ import com.swmansion.enriched.common.spans.EnrichedUnderlineSpan import com.swmansion.enriched.common.spans.EnrichedUnorderedListSpan interface EnrichedSpanFactory { + fun createAlignmentSpan(cssValue: String): EnrichedAlignmentSpan + fun createBoldSpan(style: T): EnrichedBoldSpan fun createItalicSpan(style: T): EnrichedItalicSpan diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedAlignmentSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedAlignmentSpan.kt new file mode 100644 index 000000000..d4237c76b --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedAlignmentSpan.kt @@ -0,0 +1,19 @@ +package com.swmansion.enriched.common.spans + +import android.text.Layout +import android.text.style.AlignmentSpan +import com.swmansion.enriched.common.spans.interfaces.EnrichedZeroWidthSpaceSpan + +open class EnrichedAlignmentSpan( + val cssValue: String, +) : AlignmentSpan.Standard(cssValueToLayoutAlignment(cssValue)), + EnrichedZeroWidthSpaceSpan { + companion object { + fun cssValueToLayoutAlignment(cssValue: String): Layout.Alignment = + when (cssValue) { + "center" -> Layout.Alignment.ALIGN_CENTER + "right" -> Layout.Alignment.ALIGN_OPPOSITE + else -> Layout.Alignment.ALIGN_NORMAL + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt index 373b169fb..d8a3a0f45 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt @@ -1,6 +1,8 @@ package com.swmansion.enriched.text import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.text.spans.EnrichedTextAlignmentSpan import com.swmansion.enriched.text.spans.EnrichedTextBlockQuoteSpan import com.swmansion.enriched.text.spans.EnrichedTextBoldSpan import com.swmansion.enriched.text.spans.EnrichedTextCheckboxListSpan @@ -22,6 +24,8 @@ import com.swmansion.enriched.text.spans.EnrichedTextUnderlineSpan import com.swmansion.enriched.text.spans.EnrichedTextUnorderedListSpan class EnrichedTextSpanFactory : EnrichedSpanFactory { + override fun createAlignmentSpan(cssValue: String) = EnrichedTextAlignmentSpan(cssValue) + override fun createBoldSpan(style: EnrichedTextStyle) = EnrichedTextBoldSpan(style) override fun createItalicSpan(style: EnrichedTextStyle) = EnrichedTextItalicSpan(style) diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextAlignmentSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextAlignmentSpan.kt new file mode 100644 index 000000000..4e8e3d648 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextAlignmentSpan.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextAlignmentSpan( + cssValue: String, +) : EnrichedAlignmentSpan(cssValue), + EnrichedTextSpan { + override val dependsOnHtmlStyle = false + + override fun rebuildWithStyle(style: EnrichedTextStyle) = this +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt index 0d776c8ed..3c75e042a 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt @@ -1,8 +1,10 @@ package com.swmansion.enriched.textinput import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan import com.swmansion.enriched.common.spans.EnrichedImageSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan import com.swmansion.enriched.textinput.spans.EnrichedInputBlockQuoteSpan import com.swmansion.enriched.textinput.spans.EnrichedInputBoldSpan import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan @@ -25,6 +27,8 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan import com.swmansion.enriched.textinput.styles.HtmlStyle class EnrichedTextInputSpannableFactory : EnrichedSpanFactory { + override fun createAlignmentSpan(cssValue: String) = EnrichedInputAlignmentSpan(cssValue) + override fun createBoldSpan(style: HtmlStyle) = EnrichedInputBoldSpan(style) override fun createItalicSpan(style: HtmlStyle) = EnrichedInputItalicSpan(style) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index 885a12ea4..e65effef1 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -57,6 +57,7 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputImageSpan import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan +import com.swmansion.enriched.textinput.styles.AlignmentStyles import com.swmansion.enriched.textinput.styles.HtmlStyle import com.swmansion.enriched.textinput.styles.InlineStyles import com.swmansion.enriched.textinput.styles.ListStyles @@ -85,6 +86,7 @@ class EnrichedTextInputView : val paragraphStyles: ParagraphStyles? = ParagraphStyles(this) val listStyles: ListStyles? = ListStyles(this) val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this) + val alignmentStyles: AlignmentStyles? = AlignmentStyles(this) var isDuringTransaction: Boolean = false var isRemovingMany: Boolean = false var scrollEnabled: Boolean = true @@ -141,6 +143,16 @@ class EnrichedTextInputView : prepareComponent() } + override fun scrollTo( + x: Int, + y: Int, + ) { + // Android's internal cursor tracker gets confused by ALIGN_CENTER + LeadingMarginSpan + // and attempts to scroll the text horizontally. + // We lock the horizontal scroll to 0 to prevent the view from shifting. + super.scrollTo(0, y) + } + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { var inputConnection = super.onCreateInputConnection(outAttrs) @@ -962,6 +974,14 @@ class EnrichedTextInputView : parametrizedStyles?.setMentionSpan(text, indicator, attributes) } + fun setTextAlignment(alignment: String) { + runAsATransaction { + alignmentStyles?.setAlignment(alignment) + } + selection?.validateStyles() + layoutManager.invalidateLayout() + } + fun requestHTML(requestId: Int) { val html = try { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 7b5313462..998689f6e 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -452,7 +452,7 @@ class EnrichedTextInputViewManager : view: EnrichedTextInputView?, alignment: String, ) { - TODO("Not yet implemented") + view?.setTextAlignment(alignment) } override fun measure( diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputAlignmentSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputAlignmentSpan.kt new file mode 100644 index 000000000..c64c84185 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputAlignmentSpan.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.textinput.spans + +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan +import com.swmansion.enriched.textinput.styles.HtmlStyle + +class EnrichedInputAlignmentSpan( + cssValue: String, +) : EnrichedAlignmentSpan(cssValue), + EnrichedInputSpan { + override val dependsOnHtmlStyle: Boolean = false + + override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedInputAlignmentSpan = this +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt new file mode 100644 index 000000000..07503ad23 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt @@ -0,0 +1,348 @@ +package com.swmansion.enriched.textinput.styles + +import android.text.Editable +import android.text.Spannable +import android.text.SpannableStringBuilder +import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.textinput.EnrichedTextInputView +import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputOrderedListSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan +import com.swmansion.enriched.textinput.utils.getParagraphBounds +import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries +import com.swmansion.enriched.textinput.utils.safelyInsertZWS + +class AlignmentStyles( + private val view: EnrichedTextInputView, +) { + private fun setAlignmentSpan( + spannable: Spannable, + cssValue: String, + start: Int, + end: Int, + flags: Int = Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) { + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) + spannable.setSpan( + EnrichedInputAlignmentSpan(cssValue), + safeStart, + safeEnd, + flags, + ) + } + + private fun toCssValue(alignment: String): String = + when (alignment) { + "center" -> "center" + "right" -> "right" + "left" -> "left" + else -> "auto" + } + + private fun getActiveListSpanType( + spannable: Spannable, + start: Int, + end: Int, + ): Class<*>? { + if (spannable.getSpans(start, end, EnrichedInputUnorderedListSpan::class.java).isNotEmpty()) { + return EnrichedInputUnorderedListSpan::class.java + } + if (spannable.getSpans(start, end, EnrichedInputOrderedListSpan::class.java).isNotEmpty()) { + return EnrichedInputOrderedListSpan::class.java + } + if (spannable.getSpans(start, end, EnrichedInputCheckboxListSpan::class.java).isNotEmpty()) { + return EnrichedInputCheckboxListSpan::class.java + } + return null + } + + /** + * Expands [start, end] to cover all contiguous paragraphs that belong to the same list type. + * Alignment changes must apply to entire lists, not individual items within them. + */ + private fun expandRangeToContiguousList( + spannable: Spannable, + start: Int, + end: Int, + ): Pair { + if (spannable.isEmpty()) return Pair(start, end) + + var expandedStart = start + var expandedEnd = end + + // Expand backward through paragraphs of the same list type. + var (currentParaStart, currentParaEnd) = spannable.getParagraphBounds(start) + val activeStartType = getActiveListSpanType(spannable, currentParaStart, currentParaEnd) + + if (activeStartType != null) { + expandedStart = currentParaStart + while (currentParaStart > 0) { + val (prevParaStart, prevParaEnd) = spannable.getParagraphBounds(currentParaStart - 1) + if (getActiveListSpanType(spannable, prevParaStart, prevParaEnd) == activeStartType) { + expandedStart = prevParaStart + currentParaStart = prevParaStart + } else { + break + } + } + } + + // Expand forward through paragraphs of the same list type. + val endLoc = if (end > start) end - 1 else start + var (endParaStart, endParaEnd) = spannable.getParagraphBounds(endLoc) + val activeEndType = getActiveListSpanType(spannable, endParaStart, endParaEnd) + + if (activeEndType != null) { + expandedEnd = endParaEnd + while (endParaEnd < spannable.length) { + val nextCursor = endParaEnd + 1 + if (nextCursor >= spannable.length) break + + val (nextParaStart, nextParaEnd) = spannable.getParagraphBounds(nextCursor) + if (getActiveListSpanType(spannable, nextParaStart, nextParaEnd) == activeEndType) { + expandedEnd = nextParaEnd + endParaEnd = nextParaEnd + } else { + break + } + } + } + + return Pair(expandedStart, expandedEnd) + } + + fun afterTextChanged( + s: Editable, + cursorPosition: Int, + deletedText: String, + anchorAlignmentToRestore: String? = null, + ) { + val isJustNewline = cursorPosition > 0 && s[cursorPosition - 1] == '\n' + val isOtherStyleInjectedZws = + cursorPosition > 1 && + s[cursorPosition - 1].toString() == EnrichedConstants.ZWS_STRING && + s[cursorPosition - 2] == '\n' + val isNewLineInserted = deletedText.isEmpty() && (isJustNewline || isOtherStyleInjectedZws) + + val includesNewlineDeletion = deletedText.contains('\n') + val isZwsDeleted = deletedText == EnrichedConstants.ZWS_STRING + + // Track cursor separately because deleting a newline shifts text indices. + var activeCursor = cursorPosition + + // Exit early if no alignment spans exist in the current or previous paragraph. + if (anchorAlignmentToRestore == null) { + val bounded = activeCursor.coerceIn(0, s.length) + val (paraStart, paraEnd) = s.getParagraphBounds(bounded) + val prevStart = if (paraStart > 0) s.getParagraphBounds(paraStart - 1).first else 0 + if (s.getSpans(prevStart, paraEnd, EnrichedInputAlignmentSpan::class.java).isEmpty()) { + view.selection?.validateStyles() + return + } + } + + if (isNewLineInserted) { + activeCursor = handleNewlineInheritance(s, activeCursor, isOtherStyleInjectedZws) + autoStretchAlignmentSpan(s, activeCursor) + } else if (isZwsDeleted && anchorAlignmentToRestore != null) { + val (paraStart, paraEnd) = s.getParagraphBounds(activeCursor) + if (paraStart == paraEnd) { + (s as SpannableStringBuilder).safelyInsertZWS(activeCursor) + + setAlignmentSpan(s, anchorAlignmentToRestore, activeCursor, activeCursor + 1) + } + autoStretchAlignmentSpan(s, activeCursor) + } else { + view.runAsATransaction { + if (isZwsDeleted) { + activeCursor = handleZwsBackspace(s, activeCursor) + } else if (includesNewlineDeletion) { + activeCursor = handleParagraphMerge(s, activeCursor) + } + + autoStretchAlignmentSpan(s, activeCursor) + } + } + + view.selection?.validateStyles() + } + + /** + * Ensures the alignment span perfectly wraps the paragraph boundaries at [activeCursor]. + * If a newline was inserted mid-paragraph the span is split; otherwise it is stretched + * to cover any newly typed text. + */ + private fun autoStretchAlignmentSpan( + s: Editable, + activeCursor: Int, + ) { + val (paraStart, paraEnd) = s.getParagraphBounds(activeCursor) + val spans = s.getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + + if (spans.isEmpty()) return + + val dominantCss = spans.first().cssValue + for (span in spans) { + val sStart = s.getSpanStart(span) + val sEnd = s.getSpanEnd(span) + val cssValue = span.cssValue + + s.removeSpan(span) + + // Preserve the top fragment when a newline split an aligned paragraph. + if (sStart < paraStart) { + setAlignmentSpan(s, cssValue, sStart, paraStart) + } + // Preserve the bottom fragment. + if (sEnd > paraEnd) { + setAlignmentSpan(s, cssValue, paraEnd, sEnd) + } + + // Re-apply to the current paragraph with exact bounds. + setAlignmentSpan(s, dominantCss, paraStart, paraEnd) + } + } + + fun setAlignment(alignment: String) { + val spannable = view.text as? SpannableStringBuilder ?: return + val selection = view.selection ?: return + + val (rawStart, rawEnd) = selection.getParagraphSelection() + val (start, end) = expandRangeToContiguousList(spannable, rawStart, rawEnd) + val cssValue = toCssValue(alignment) + + var shiftedEnd = end + var cursor = start + + while (cursor <= shiftedEnd) { + val (paraStart, paraEnd) = spannable.getParagraphBounds(cursor) + + cleanUpExistingSpans(spannable, paraStart, paraEnd) + + if (cssValue != "auto") { + if (paraStart == paraEnd) { + // Empty paragraph: anchor alignment with a ZWS so the cursor sits inside the span. + spannable.safelyInsertZWS(paraStart) + setAlignmentSpan(spannable, cssValue, paraStart, paraStart + 1) + + shiftedEnd++ + if (paraStart + 1 >= shiftedEnd) break + cursor = paraStart + 2 + continue + } else { + setAlignmentSpan(spannable, cssValue, paraStart, paraEnd) + } + } + + if (paraEnd >= shiftedEnd || paraEnd == spannable.length) break + cursor = paraEnd + 1 + } + } + + fun getCurrentAlignment(): String { + val spannable = view.text as? Spannable ?: return "auto" + val selection = view.selection ?: return "auto" + + val cursorPos = selection.start.coerceAtLeast(0).coerceAtMost(spannable.length) + val (paraStart, paraEnd) = spannable.getParagraphBounds(cursorPos) + val spans = spannable.getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + + return spans.firstOrNull()?.cssValue ?: "auto" + } + + private fun handleZwsBackspace( + s: Editable, + cursorPosition: Int, + ): Int { + if (cursorPosition > 0 && s[cursorPosition - 1] == '\n') { + val (currentParaStart, currentParaEnd) = s.getParagraphBounds(cursorPosition) + s + .getSpans(currentParaStart, currentParaEnd, EnrichedInputAlignmentSpan::class.java) + .forEach { s.removeSpan(it) } + s.delete(cursorPosition - 1, cursorPosition) + view.setSelection(cursorPosition - 1) + return cursorPosition - 1 + } else if (cursorPosition == 0) { + val (paraStart, paraEnd) = s.getParagraphBounds(0) + s + .getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + .forEach { s.removeSpan(it) } + return 0 + } + return cursorPosition + } + + private fun handleParagraphMerge( + s: Editable, + cursorPosition: Int, + ): Int { + val (paraStart, paraEnd) = s.getParagraphBounds(cursorPosition) + val spans = s.getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + var dominantTopSpan: EnrichedInputAlignmentSpan? = null + + spans.forEach { span -> + if (s.getSpanStart(span) >= cursorPosition) { + s.removeSpan(span) + } else { + dominantTopSpan = span + } + } + + // INCLUSIVE_EXCLUSIVE is intentional here: autoStretchAlignmentSpan will convert + // it to EXCLUSIVE_EXCLUSIVE once the merge is complete. + val (safeStart, safeEnd) = s.getSafeSpanBoundaries(paraStart, paraEnd) + dominantTopSpan?.let { s.setSpan(it, safeStart, safeEnd, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) } + return cursorPosition + } + + private fun handleNewlineInheritance( + s: Editable, + cursorPosition: Int, + isZwsInjected: Boolean, + ): Int { + val prevCharIndex = (if (isZwsInjected) cursorPosition - 2 else cursorPosition - 1) - 1 + if (prevCharIndex < 0) return cursorPosition + + val (prevParaStart, prevParaEnd) = s.getParagraphBounds(prevCharIndex) + val prevSpan = + s + .getSpans(prevParaStart, prevParaEnd, EnrichedInputAlignmentSpan::class.java) + .firstOrNull() ?: return cursorPosition + + val (newParaStart, newParaEnd) = s.getParagraphBounds(cursorPosition) + + return if (newParaStart == newParaEnd) { + (s as SpannableStringBuilder).safelyInsertZWS(cursorPosition) + setAlignmentSpan(s, prevSpan.cssValue, cursorPosition, cursorPosition + 1) + cursorPosition + 1 + } else { + setAlignmentSpan(s, prevSpan.cssValue, newParaStart, newParaEnd) + cursorPosition + } + } + + /** + * Removes all alignment spans that overlap [paraStart, paraEnd], trimming any span + * that extends beyond the paragraph rather than deleting it entirely. + */ + private fun cleanUpExistingSpans( + spannable: SpannableStringBuilder, + paraStart: Int, + paraEnd: Int, + ) { + val existing = spannable.getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + for (span in existing) { + val sStart = spannable.getSpanStart(span) + val sEnd = spannable.getSpanEnd(span) + spannable.removeSpan(span) + + if (sStart < paraStart) { + setAlignmentSpan(spannable, span.cssValue, sStart, paraStart) + } + if (sEnd > paraEnd) { + setAlignmentSpan(spannable, span.cssValue, paraEnd, sEnd) + } + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt index 01801239e..2f0885caa 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt @@ -12,7 +12,8 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.utils.getParagraphBounds import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries -import com.swmansion.enriched.textinput.utils.removeZWS +import com.swmansion.enriched.textinput.utils.safelyInsertZWS +import com.swmansion.enriched.textinput.utils.safelyRemoveZWS class ListStyles( private val view: EnrichedTextInputView, @@ -98,7 +99,7 @@ class ListStyles( ssb.removeSpan(span) } - ssb.removeZWS(start, end) + ssb.safelyRemoveZWS(start, end) return true } @@ -134,10 +135,12 @@ class ListStyles( } if (start == end) { - spannable.insert(start, EnrichedConstants.ZWS_STRING) - view.spanState?.setStart(name, start + 1) + val wasInserted = spannable.safelyInsertZWS(start) + val shift = if (wasInserted) 1 else 0 + + view.spanState?.setStart(name, start + shift) removeSpansForRange(spannable, start, end, config.clazz) - setSpan(spannable, name, start, end + 1, checkboxState) + setSpan(spannable, name, start, end + shift, checkboxState) return } @@ -147,10 +150,12 @@ class ListStyles( removeSpansForRange(spannable, start, end, config.clazz) for (paragraph in paragraphs) { - spannable.insert(currentStart, EnrichedConstants.ZWS_STRING) - val currentEnd = currentStart + paragraph.length + 1 + val wasInserted = spannable.safelyInsertZWS(currentStart) + val shift = if (wasInserted) 1 else 0 + val currentEnd = currentStart + paragraph.length + shift setSpan(spannable, name, currentStart, currentEnd, checkboxState) + // Safely jump exactly 1 character over the '\n' to the next line currentStart = currentEnd + 1 } @@ -208,7 +213,7 @@ class ListStyles( } } - s.insert(cursorPosition, EnrichedConstants.ZWS_STRING) + (s as SpannableStringBuilder).safelyInsertZWS(cursorPosition) setSpan(s, name, start, end + 1) // Inform that new span has been added view.selection?.validateStyles() diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParagraphStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParagraphStyles.kt index 09b06271d..577c0abba 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ParagraphStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ParagraphStyles.kt @@ -4,13 +4,13 @@ import android.text.Editable import android.text.Spannable import android.text.SpannableStringBuilder import android.util.Log -import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan import com.swmansion.enriched.textinput.utils.getParagraphBounds import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries -import com.swmansion.enriched.textinput.utils.removeZWS +import com.swmansion.enriched.textinput.utils.safelyInsertZWS +import com.swmansion.enriched.textinput.utils.safelyRemoveZWS class ParagraphStyles( private val view: EnrichedTextInputView, @@ -135,7 +135,7 @@ class ParagraphStyles( } } - ssb.removeZWS(start, end) + ssb.safelyRemoveZWS(start, end) return true } @@ -350,8 +350,9 @@ class ParagraphStyles( spanState.setStart(style, null) continue } else { - s.insert(endCursorPosition, EnrichedConstants.ZWS_STRING) - endCursorPosition += 1 + val wasInserted = (s as SpannableStringBuilder).safelyInsertZWS(endCursorPosition) + val shift = if (wasInserted) 1 else 0 + endCursorPosition += shift } } @@ -399,8 +400,9 @@ class ParagraphStyles( } if (start == end) { - spannable.insert(start, EnrichedConstants.ZWS_STRING) - setAndMergeSpans(spannable, type, start, end + 1) + val wasInserted = spannable.safelyInsertZWS(start) + val shift = if (wasInserted) 1 else 0 + setAndMergeSpans(spannable, type, start, end + shift) view.selection.validateStyles() return @@ -411,8 +413,11 @@ class ParagraphStyles( val paragraphs = spannable.substring(start, end).split("\n") for (paragraph in paragraphs) { - spannable.insert(currentStart, EnrichedConstants.ZWS_STRING) - currentEnd = currentStart + paragraph.length + 1 + val wasInserted = spannable.safelyInsertZWS(currentStart) + val shift = if (wasInserted) 1 else 0 + currentEnd = currentStart + paragraph.length + shift + + // Safely jump exactly 1 character over the '\n' to the next line currentStart = currentEnd + 1 } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt index 64239d295..58464eba5 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt @@ -104,11 +104,15 @@ class EnrichedSelection( for ((style, config) in EnrichedSpans.parametrizedStyles) { state.setStart(style, getParametrizedStyleStart(config.clazz)) } + + val currentAlignment = view.alignmentStyles?.getCurrentAlignment() ?: "auto" + state.setAlignment(currentAlignment) } fun getInlineSelection(): Pair { - val finalStart = start.coerceAtMost(end).coerceAtLeast(0) - val finalEnd = end.coerceAtLeast(start).coerceAtLeast(0) + val textLength = view.text?.length ?: 0 + val finalStart = start.coerceAtMost(end).coerceAtLeast(0).coerceAtMost(textLength) + val finalEnd = end.coerceAtLeast(start).coerceAtLeast(0).coerceAtMost(textLength) return Pair(finalStart, finalEnd) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt index 67e9f9b15..269229185 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt @@ -52,6 +52,13 @@ class EnrichedSpanState( private set var mentionStart: Int? = null private set + var currentAlignment: String = "auto" + private set + + fun setAlignment(value: String) { + this.currentAlignment = value + emitStateChangeEvent() + } fun setBoldStart(start: Int?) { this.boldStart = start @@ -246,6 +253,7 @@ class EnrichedSpanState( payload.putMap("image", getStyleState(activeStyles, EnrichedSpans.IMAGE)) payload.putMap("mention", getStyleState(activeStyles, EnrichedSpans.MENTION)) payload.putMap("checkboxList", getStyleState(activeStyles, EnrichedSpans.CHECKBOX_LIST)) + payload.putString("alignment", currentAlignment) return payload } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannableStringBuilder.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannableStringBuilder.kt index e4aa32cc0..ce9a0ff92 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannableStringBuilder.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannableStringBuilder.kt @@ -2,6 +2,11 @@ package com.swmansion.enriched.textinput.utils import android.text.SpannableStringBuilder import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputOrderedListSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan +import com.swmansion.enriched.textinput.spans.EnrichedSpans fun CharSequence.zwsCountBefore(index: Int): Int { var count = 0 @@ -22,3 +27,35 @@ fun SpannableStringBuilder.removeZWS( } } } + +/** + * Inserts a ZWS at [index] if missing, preventing duplicate anchors. + * Returns true if inserted. + */ +fun SpannableStringBuilder.safelyInsertZWS(index: Int): Boolean { + if (index < 0 || index > length) return false + if (length > index && this[index] == EnrichedConstants.ZWS) return false + + insert(index, EnrichedConstants.ZWS_STRING) + return true +} + +/** + * Removes ZWS in [start, end) only if no alignment still requires them as a layout anchor. + */ +fun SpannableStringBuilder.safelyRemoveZWS( + start: Int, + end: Int, +) { + val safeStart = start.coerceAtLeast(0) + val safeEnd = end.coerceAtMost(length) + if (safeStart >= safeEnd) return + + val hasAlignment = getSpans(safeStart, safeEnd, EnrichedInputAlignmentSpan::class.java).isNotEmpty() + + if (hasAlignment) return + + for (i in (safeEnd - 1) downTo safeStart) { + if (this[i] == EnrichedConstants.ZWS) delete(i, i + 1) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index 028b41c15..0751dddfe 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt @@ -1,11 +1,15 @@ package com.swmansion.enriched.textinput.watchers import android.text.Editable +import android.text.Spannable import android.text.TextWatcher import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper +import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.events.OnChangeTextEvent +import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan +import com.swmansion.enriched.textinput.spans.EnrichedSpans class EnrichedTextWatcher( private val view: EnrichedTextInputView, @@ -14,6 +18,9 @@ class EnrichedTextWatcher( private var startCursorPosition: Int = 0 private var previousTextLength: Int = 0 + private var deletedText: String = "" + private var anchorAlignmentToRestore: String? = null + override fun beforeTextChanged( s: CharSequence?, start: Int, @@ -21,6 +28,28 @@ class EnrichedTextWatcher( after: Int, ) { previousTextLength = s?.length ?: 0 + deletedText = if (count > 0 && s != null) s.substring(start, start + count) else "" + + anchorAlignmentToRestore = null + + // When a ZWS is being deleted, check whether it was anchoring a list or paragraph + // style. If so, capture the co-located alignment value so AlignmentStyles can + // re-anchor it after the deletion completes. + if (deletedText == EnrichedConstants.ZWS_STRING && s is Spannable) { + val isBlockAnchor = + EnrichedSpans.listSpans.values + .any { config -> s.getSpans(start, start + 1, config.clazz).isNotEmpty() } || + EnrichedSpans.paragraphSpans.values + .any { config -> s.getSpans(start, start + 1, config.clazz).isNotEmpty() } + + if (isBlockAnchor) { + anchorAlignmentToRestore = + s + .getSpans(start, start + 1, EnrichedInputAlignmentSpan::class.java) + .firstOrNull() + ?.cssValue + } + } } override fun onTextChanged( @@ -47,6 +76,7 @@ class EnrichedTextWatcher( view.inlineStyles?.afterTextChanged(s, endCursorPosition) view.paragraphStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) view.listStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) + view.alignmentStyles?.afterTextChanged(s, endCursorPosition, deletedText, anchorAlignmentToRestore) view.parametrizedStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) } diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts index 196377a03..f4868926d 100644 --- a/apps/example/src/constants/editorConfig.ts +++ b/apps/example/src/constants/editorConfig.ts @@ -32,7 +32,7 @@ export const DEFAULT_STYLES: StylesState = { image: DEFAULT_STYLE_STATE, mention: DEFAULT_STYLE_STATE, checkboxList: DEFAULT_STYLE_STATE, - alignment: 'left', + alignment: 'auto', }; export const DEFAULT_LINK_STATE = { diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index b3d0efa8a..d681a3bc7 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -301,6 +301,9 @@ interface OnChangeStateEvent { - `isConflicting` indicates if the style is in conflict with other currently active styles, meaning toggling it will remove conflicting style. - `alignment` indicates the current text alignment of the paragraph at the cursor position. Possible values: `'left'`, `'center'`, `'right'`, `'justify'`, `'auto'`. +> [!NOTE] +> On Android, `'justify'` is not supported. It is accepted in the type signature but has no justified layout effect — text is shown with natural alignment instead, the same as `'auto'`. On iOS, justified alignment works as expected. + | Type | Platform | |-------------------------------------------------------------|----------| | `(event: NativeSyntheticEvent) => void` | Both | @@ -617,7 +620,7 @@ Sets text alignment for the paragraph(s) at the current selection. When inside a - `alignment` - the desired text alignment. Use `'auto'` to reset to the system natural alignment. > [!NOTE] -> This method is iOS only for now. +> On Android, `'justify'` is not supported. Calling `setTextAlignment('justify')` does not apply justified text — the paragraph ends up with natural alignment, the same as `'auto'`. On iOS, justified alignment works as expected. ### `.startMention()`