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()`