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 9a03613d6..9880a3512 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 @@ -35,6 +35,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; @@ -157,6 +159,17 @@ private static String getBlockTag(EnrichedParagraphSpan[] spans) { return "p"; } + private static String getTextAlignStyleValue(Layout.Alignment alignment) { + if (alignment == null || alignment == Layout.Alignment.ALIGN_NORMAL) { + return null; + } else if (alignment == Layout.Alignment.ALIGN_CENTER) { + return "center"; + } else if (alignment == Layout.Alignment.ALIGN_OPPOSITE) { + return "right"; + } + return null; + } + private static void withinBlock(StringBuilder out, Spanned text, int start, int end) { boolean isInUlList = false; boolean isInOlList = false; @@ -234,6 +247,18 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int } } + ParagraphStyle[] paragraphStyleSpans = text.getSpans(i, next, ParagraphStyle.class); + for (ParagraphStyle paragraphStyle : paragraphStyleSpans) { + if (paragraphStyle instanceof AlignmentSpan) { + String alignment = + getTextAlignStyleValue(((AlignmentSpan) paragraphStyle).getAlignment()); + if (alignment != null) { + out.append(" style=\"text-align: ").append(alignment).append("\""); + } + break; + } + } + out.append(">"); withinParagraph(out, text, i, next); out.append(" 0) 0f else x.toFloat() + canvas.withTranslation(markerBaseX + enrichedStyle.ulCheckboxMarginLeft, drawableTop) { checkboxDrawable.draw(this) } } diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedOrderedListSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedOrderedListSpan.kt index d57619c91..345b60e74 100644 --- a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedOrderedListSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedOrderedListSpan.kt @@ -46,7 +46,8 @@ open class EnrichedOrderedListSpan( val width = paint.measureText(text) val yPosition = baseline.toFloat() - val xPosition = (enrichedStyle.olMarginLeft + x - width / 2) * dir + val markerBaseX = if (dir > 0) 0 else x + val xPosition = (enrichedStyle.olMarginLeft + markerBaseX - width / 2) * dir val originalColor = paint.color val originalTypeface = paint.typeface diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedUnorderedListSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedUnorderedListSpan.kt index a9abd6090..ed732f59c 100644 --- a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedUnorderedListSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedUnorderedListSpan.kt @@ -49,9 +49,9 @@ open class EnrichedUnorderedListSpan( paint.style = Paint.Style.FILL val bulletRadius = enrichedStyle.ulBulletSize / 2f - val fm = paint.fontMetricsInt - val yPosition = baseline + (fm.ascent + fm.descent) / 2f - val xPosition = x + dir * bulletRadius + enrichedStyle.ulMarginLeft + val yPosition = (top + bottom) / 2f + val markerBaseX = if (dir > 0) 0 else x + val xPosition = markerBaseX + dir * bulletRadius + enrichedStyle.ulMarginLeft canvas.drawCircle(xPosition, yPosition, bulletRadius, paint) 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 dd6e9cf55..c2f21ae81 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -9,8 +9,13 @@ import android.graphics.Color import android.graphics.Rect import android.graphics.text.LineBreaker import android.os.Build +import android.text.Editable import android.text.InputType +import android.text.Layout import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.AlignmentSpan import android.util.AttributeSet import android.util.Log import android.util.Patterns @@ -49,9 +54,13 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputH3Span import com.swmansion.enriched.textinput.spans.EnrichedInputH4Span import com.swmansion.enriched.textinput.spans.EnrichedInputH5Span import com.swmansion.enriched.textinput.spans.EnrichedInputH6Span +import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan import com.swmansion.enriched.textinput.spans.EnrichedInputImageSpan import com.swmansion.enriched.textinput.spans.EnrichedInputLinkSpan import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputOrderedListSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan +import com.swmansion.enriched.textinput.spans.EnrichedAlignmentPlaceholderSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan import com.swmansion.enriched.textinput.styles.HtmlStyle @@ -63,6 +72,9 @@ import com.swmansion.enriched.textinput.utils.EnrichedEditableFactory import com.swmansion.enriched.textinput.utils.EnrichedSelection import com.swmansion.enriched.textinput.utils.EnrichedSpanState import com.swmansion.enriched.textinput.utils.RichContentReceiver +import com.swmansion.enriched.textinput.utils.getParagraphBounds +import com.swmansion.enriched.textinput.utils.getParagraphRangesInRange +import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries import com.swmansion.enriched.textinput.utils.mergeSpannables import com.swmansion.enriched.textinput.utils.setCheckboxClickListener import com.swmansion.enriched.textinput.utils.zwsCountBefore @@ -80,7 +92,9 @@ class EnrichedTextInputView : AppCompatEditText { val paragraphStyles: ParagraphStyles? = ParagraphStyles(this) val listStyles: ListStyles? = ListStyles(this) val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this) - var isDuringTransaction: Boolean = false + private var transactionDepth: Int = 0 + val isDuringTransaction: Boolean + get() = transactionDepth > 0 var isRemovingMany: Boolean = false var scrollEnabled: Boolean = true @@ -114,6 +128,7 @@ class EnrichedTextInputView : AppCompatEditText { private var fontWeight: Int = ReactConstants.UNSET private var defaultValue: CharSequence? = null private var defaultValueDirty: Boolean = false + private var typingAlignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL private var inputMethodManager: InputMethodManager? = null private val spannableFactory = EnrichedTextInputSpannableFactory() @@ -216,6 +231,14 @@ class EnrichedTextInputView : AppCompatEditText { override fun canScrollHorizontally(direction: Int): Boolean = scrollEnabled + override fun bringPointIntoView(offset: Int): Boolean { + val result = super.bringPointIntoView(offset) + if (scrollX != 0) { + scrollTo(0, scrollY) + } + return result + } + override fun onSelectionChanged( selStart: Int, selEnd: Int, @@ -648,6 +671,491 @@ class EnrichedTextInputView : AppCompatEditText { ) } + fun setTextAlignment(alignment: String) { + val layoutAlignment = + when (alignment.lowercase()) { + "left", "default" -> Layout.Alignment.ALIGN_NORMAL + "center" -> Layout.Alignment.ALIGN_CENTER + "right" -> Layout.Alignment.ALIGN_OPPOSITE + "justify" -> Layout.Alignment.ALIGN_NORMAL + else -> Layout.Alignment.ALIGN_NORMAL + } + + val spannable = text as? SpannableStringBuilder ?: return + typingAlignment = layoutAlignment + + if (spannable.isEmpty()) { + if (layoutAlignment != Layout.Alignment.ALIGN_NORMAL) { + runAsATransaction { + spannable.insert(0, EnrichedConstants.ZWS_STRING) + spannable.setSpan( + EnrichedAlignmentPlaceholderSpan(), + 0, + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + spannable.setSpan( + AlignmentSpan.Standard(layoutAlignment), + 0, + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + setSelection(1) + layoutManager.invalidateLayout() + requestLayout() + invalidate() + } + return + } + + val (initialStart, initialEnd) = + selection?.getParagraphSelection() + ?: Pair(selectionStart.coerceIn(0, spannable.length), selectionEnd.coerceIn(0, spannable.length)) + val (start, end) = spannable.getSafeSpanBoundaries(initialStart, initialEnd) + val targetRange = expandRangeToContiguousList(spannable, start, end) + val paragraphRanges = spannable.getParagraphRangesInRange(targetRange.first, targetRange.second) + + val cursorStart = selectionStart + val cursorEnd = selectionEnd + runAsATransaction { + for ((paragraphStart, paragraphEnd) in paragraphRanges) { + removeAlignmentSpans(spannable, paragraphStart, paragraphEnd) + if (layoutAlignment != Layout.Alignment.ALIGN_NORMAL && paragraphStart < paragraphEnd) { + spannable.setSpan( + AlignmentSpan.Standard(layoutAlignment), + paragraphStart, + paragraphEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + } + + setSelection(cursorStart.coerceIn(0, spannable.length), cursorEnd.coerceIn(0, spannable.length)) + layoutManager.invalidateLayout() + requestLayout() + invalidate() + spanState?.emitStateChangeEvent() + } + + fun getCurrentAlignment(): String { + val spannable = text as? Spannable ?: return "left" + val (start, end) = selection?.getParagraphSelection() ?: Pair(selectionStart, selectionEnd) + val alignment = getParagraphAlignment(spannable, start, end) + return when (alignment) { + Layout.Alignment.ALIGN_NORMAL -> "left" + Layout.Alignment.ALIGN_CENTER -> "center" + Layout.Alignment.ALIGN_OPPOSITE -> "right" + } + } + + fun applyTypingAlignmentIfNeeded( + editable: Editable, + changeStart: Int, + changeEnd: Int, + previousTextLength: Int, + deletedAlignmentPlaceholder: Boolean = false, + ) { + val spannable = editable as? SpannableStringBuilder ?: return + + if (spannable.isEmpty()) { + typingAlignment = Layout.Alignment.ALIGN_NORMAL + return + } + + if (spannable.length < previousTextLength) { + if (!deletedAlignmentPlaceholder && typingAlignment != Layout.Alignment.ALIGN_NORMAL) { + val cursorPos = changeStart.coerceIn(0, spannable.length) + val (pStart, pEnd) = spannable.getParagraphBounds(cursorPos, cursorPos) + if (pStart == pEnd && + getParagraphAlignment(spannable, pStart, pEnd) == Layout.Alignment.ALIGN_NORMAL && + !paragraphHasListSpan(spannable, pStart, pEnd) + ) { + runAsATransaction { + spannable.insert(pStart, EnrichedConstants.ZWS_STRING) + spannable.setSpan( + EnrichedAlignmentPlaceholderSpan(), + pStart, + pStart + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + spannable.setSpan( + AlignmentSpan.Standard(typingAlignment), + pStart, + pStart + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + setSelection(pStart + 1) + layoutManager.invalidateLayout() + requestLayout() + invalidate() + } + } + return + } + + val safeStart = changeStart.coerceIn(0, spannable.length) + val safeEnd = changeEnd.coerceIn(safeStart, spannable.length) + if (safeStart >= safeEnd) return + + val alignment = typingAlignment + var changedAlignment = ensureAlignedEmptyParagraphPlaceholders(spannable, safeStart, safeEnd) + var index = safeStart + while (index < safeEnd) { + if (spannable[index] == '\n') { + index++ + continue + } + + val (paragraphStart, paragraphEnd) = spannable.getParagraphBounds(index, index) + + if (paragraphHasListSpan(spannable, paragraphStart, paragraphEnd)) { + if (ensureParagraphAlignmentSpan(spannable, paragraphStart, paragraphEnd, alignment)) { + changedAlignment = true + } + index = if (paragraphEnd > index) paragraphEnd else index + 1 + continue + } + + val hadPlaceholder = removeLeadingAlignmentPlaceholderIfNeeded(spannable, paragraphStart, paragraphEnd) + val adjustedParagraphEnd = + if (hadPlaceholder) { + (paragraphEnd - 1).coerceAtLeast(paragraphStart) + } else { + paragraphEnd + } + val alignmentChanged = ensureParagraphAlignmentSpan(spannable, paragraphStart, adjustedParagraphEnd, alignment) + if (hadPlaceholder || alignmentChanged) { + changedAlignment = true + } + + index = if (adjustedParagraphEnd > index) adjustedParagraphEnd else index + 1 + } + + typingAlignment = alignment + if (changedAlignment) { + layoutManager.invalidateLayout() + requestLayout() + invalidate() + post { + val currentText = text as? Spannable ?: return@post + val cursor = selectionStart.coerceIn(0, currentText.length) + + if (cursor < currentText.length && + currentText[cursor] == EnrichedConstants.ZWS && + currentText.getSpans(cursor, cursor + 1, EnrichedAlignmentPlaceholderSpan::class.java).isNotEmpty() + ) { + val newCursor = (cursor + 1).coerceAtMost(currentText.length) + setSelection(newCursor) + bringPointIntoView(newCursor) + return@post + } + + bringPointIntoView(cursor) + } + } + } + + fun syncTypingAlignmentWithSelection( + selStart: Int = selectionStart, + selEnd: Int = selectionEnd, + ) { + val spannable = text as? Spannable ?: run { + typingAlignment = Layout.Alignment.ALIGN_NORMAL + return + } + + if (spannable.isEmpty()) { + typingAlignment = Layout.Alignment.ALIGN_NORMAL + return + } + + val safeStart = selStart.coerceIn(0, spannable.length) + val safeEnd = selEnd.coerceIn(0, spannable.length) + val resolved = resolveTypingAlignmentForSelection(spannable, safeStart, safeEnd) + + // Preserve explicit typing alignment during edits/newline creation. + // Selection changes can happen before paragraph alignment spans are applied, + // and we must not downgrade a non-left typingAlignment back to left because of that. + if (typingAlignment != Layout.Alignment.ALIGN_NORMAL && resolved == Layout.Alignment.ALIGN_NORMAL) { + return + } + + typingAlignment = resolved + } + + private fun resolveTypingAlignmentForSelection( + spannable: Spannable, + start: Int, + end: Int, + ): Layout.Alignment { + val anchor = + when { + start < spannable.length -> start + start > 0 -> start - 1 + else -> 0 + } + val (paragraphStart, paragraphEnd) = spannable.getParagraphBounds(anchor, anchor) + val paragraphAlignment = getParagraphAlignment(spannable, paragraphStart, paragraphEnd) + if (paragraphAlignment != Layout.Alignment.ALIGN_NORMAL || paragraphStart < paragraphEnd || end > start) { + return paragraphAlignment + } + + if (paragraphStart > 0) { + val (previousStart, previousEnd) = spannable.getParagraphBounds(paragraphStart - 1, paragraphStart - 1) + return getParagraphAlignment(spannable, previousStart, previousEnd) + } + + return Layout.Alignment.ALIGN_NORMAL + } + + internal fun getParagraphAlignment( + spannable: Spannable, + start: Int, + end: Int, + ): Layout.Alignment { + val spans = spannable.getSpans(start, end, AlignmentSpan::class.java) + return spans.firstOrNull()?.alignment ?: Layout.Alignment.ALIGN_NORMAL + } + + private fun removeAlignmentSpans( + spannable: Spannable, + start: Int, + end: Int, + ) { + val spans = spannable.getSpans(start, end, AlignmentSpan::class.java) + for (span in spans) { + spannable.removeSpan(span) + } + } + + private fun ensureParagraphAlignmentSpan( + spannable: SpannableStringBuilder, + paragraphStart: Int, + paragraphEnd: Int, + alignment: Layout.Alignment, + ): Boolean { + if (alignment == Layout.Alignment.ALIGN_NORMAL) return false + if (paragraphStart >= paragraphEnd) return false + + val existing = spannable.getSpans(paragraphStart, paragraphEnd, AlignmentSpan::class.java) + .firstOrNull() + + if (existing == null || existing.alignment != alignment) { + runAsATransaction { + removeAlignmentSpans(spannable, paragraphStart, paragraphEnd) + spannable.setSpan( + AlignmentSpan.Standard(alignment), + paragraphStart, + paragraphEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + return true + } + + val spanStart = spannable.getSpanStart(existing) + val spanEnd = spannable.getSpanEnd(existing) + if (spanStart == paragraphStart && spanEnd >= paragraphEnd) return false + + // Grow/shrink to exactly the paragraph bounds (excludes trailing '\n') + runAsATransaction { + val flags = spannable.getSpanFlags(existing) + spannable.setSpan(existing, paragraphStart, paragraphEnd, flags) + } + return true + } + + private fun paragraphHasListSpan( + spannable: Spannable, + start: Int, + end: Int, + ): Boolean = + spannable.getSpans(start, end, EnrichedInputUnorderedListSpan::class.java).isNotEmpty() || + spannable.getSpans(start, end, EnrichedInputOrderedListSpan::class.java).isNotEmpty() || + spannable.getSpans(start, end, EnrichedInputCheckboxListSpan::class.java).isNotEmpty() + + + + /** + * Applies the current [typingAlignment] to the given paragraph range. + * Returns true if a ZWS character was inserted (changing text length by 1). + * Callers are responsible for cursor positioning and layout invalidation. + */ + fun applyTypingAlignmentToParagraphRange( + paragraphStart: Int, + paragraphEnd: Int, + manageCursorExternally: Boolean = false, + ): Boolean { + val spannable = text as? SpannableStringBuilder ?: return false + if (typingAlignment == Layout.Alignment.ALIGN_NORMAL) return false + + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(paragraphStart, paragraphEnd) + + if (safeStart >= safeEnd) { + runAsATransaction { + spannable.insert(safeStart, EnrichedConstants.ZWS_STRING) + spannable.setSpan( + EnrichedAlignmentPlaceholderSpan(), + safeStart, + safeStart + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + removeAlignmentSpans(spannable, safeStart, safeStart + 1) + spannable.setSpan( + AlignmentSpan.Standard(typingAlignment), + safeStart, + safeStart + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + if (!manageCursorExternally) { + setSelection(safeStart + 1) + } + if (!manageCursorExternally) { + layoutManager.invalidateLayout() + requestLayout() + invalidate() + } + return true + } else { + runAsATransaction { + removeAlignmentSpans(spannable, safeStart, safeEnd) + spannable.setSpan( + AlignmentSpan.Standard(typingAlignment), + safeStart, + safeEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + if (!manageCursorExternally) { + layoutManager.invalidateLayout() + requestLayout() + invalidate() + } + return false + } + } + + private fun ensureAlignedEmptyParagraphPlaceholders( + spannable: SpannableStringBuilder, + start: Int, + end: Int, + ): Boolean { + val alignment = typingAlignment + if (alignment == Layout.Alignment.ALIGN_NORMAL) return false + + var applied = false + var index = start + while (index < end && index < spannable.length) { + if (spannable[index] != '\n') { + index++ + continue + } + + val paragraphStart = index + 1 + if (paragraphStart > spannable.length) break + + val (nextParagraphStart, nextParagraphEnd) = spannable.getParagraphBounds(paragraphStart, paragraphStart) + if (nextParagraphStart == nextParagraphEnd && !paragraphHasListSpan(spannable, nextParagraphStart, nextParagraphEnd)) { + runAsATransaction { + spannable.insert(nextParagraphStart, EnrichedConstants.ZWS_STRING) + spannable.setSpan( + EnrichedAlignmentPlaceholderSpan(), + nextParagraphStart, + nextParagraphStart + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + removeAlignmentSpans(spannable, nextParagraphStart, nextParagraphStart + 1) + spannable.setSpan( + AlignmentSpan.Standard(alignment), + nextParagraphStart, + nextParagraphStart + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + applied = true + } + + index++ + } + typingAlignment = alignment + return applied + } + + private fun removeLeadingAlignmentPlaceholderIfNeeded( + spannable: SpannableStringBuilder, + start: Int, + end: Int, + ): Boolean { + if (start >= end) return false + if (spannable[start] != EnrichedConstants.ZWS) return false + if (end - start <= 1) return false + if (paragraphHasListSpan(spannable, start, end)) return false + val alignmentPlaceholderSpans = spannable.getSpans(start, start + 1, EnrichedAlignmentPlaceholderSpan::class.java) + if (alignmentPlaceholderSpans.isEmpty()) return false + + runAsATransaction { + spannable.delete(start, start + 1) + } + return true + } + + private fun expandRangeToContiguousList( + spannable: Spannable, + start: Int, + end: Int, + ): Pair { + if (spannable.length == 0) return Pair(start, end) + + val listSpanClasses = + listOf( + EnrichedInputUnorderedListSpan::class.java, + EnrichedInputOrderedListSpan::class.java, + EnrichedInputCheckboxListSpan::class.java, + ) + + val (startParagraphStart, startParagraphEnd) = spannable.getParagraphBounds(start, start) + val activeStartList = + listSpanClasses.firstOrNull { clazz -> + spannable.getSpans(startParagraphStart, startParagraphEnd, clazz).isNotEmpty() + } + + var expandedStart = start + if (activeStartList != null) { + var currentStart = startParagraphStart + while (currentStart > 0) { + val (previousStart, previousEnd) = spannable.getParagraphBounds(currentStart - 1, currentStart - 1) + if (spannable.getSpans(previousStart, previousEnd, activeStartList).isEmpty()) break + expandedStart = previousStart + currentStart = previousStart + } + } + + val endLocation = if (end > start) end - 1 else end + val (endParagraphStart, endParagraphEnd) = spannable.getParagraphBounds(endLocation, endLocation) + val activeEndList = + listSpanClasses.firstOrNull { clazz -> + spannable.getSpans(endParagraphStart, endParagraphEnd, clazz).isNotEmpty() + } + + var expandedEnd = end + if (activeEndList != null) { + var currentStart = endParagraphStart + while (currentStart < spannable.length) { + val (nextStart, nextEnd) = spannable.getParagraphBounds(currentStart, currentStart) + if (nextStart >= spannable.length) break + if (spannable.getSpans(nextStart, nextEnd, activeEndList).isEmpty()) break + expandedEnd = nextEnd.coerceAtMost(spannable.length) + currentStart = nextEnd + 1 + } + } + + return Pair(expandedStart, expandedEnd) + } + // https://github.com/facebook/react-native/blob/36df97f500aa0aa8031098caf7526db358b6ddc1/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt#L283C2-L284C1 // After the text changes inside an EditText, TextView checks if a layout() has been requested. // If it has, it will not scroll the text to the end of the new text inserted, but wait for the @@ -892,11 +1400,11 @@ class EnrichedTextInputView : AppCompatEditText { // Eg. removing conflicting styles -> changing text -> applying spans // In such scenario we want to prevent from handling side effects (eg. onTextChanged) fun runAsATransaction(block: () -> Unit) { + transactionDepth++ try { - isDuringTransaction = true block() } finally { - isDuringTransaction = false + transactionDepth = (transactionDepth - 1).coerceAtLeast(0) } } @@ -924,7 +1432,7 @@ class EnrichedTextInputView : AppCompatEditText { val maxScrollY = (textLayout.height - visibleTextHeight).coerceAtLeast(0) targetScrollY = targetScrollY.coerceIn(0, maxScrollY) - scrollTo(scrollX, targetScrollY) + scrollTo(0, targetScrollY) } private fun isHeadingBold( 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 1ba1279b1..8b5428a68 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -421,7 +421,13 @@ class EnrichedTextInputViewManager : ) { view?.requestHTML(requestId) } - + + override fun setTextAlignment( + view: EnrichedTextInputView?, + alignment: String, + ) { + view?.setTextAlignment(alignment) + } override fun measure( context: Context, localData: ReadableMap?, 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..a904f12d8 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 @@ -1,9 +1,11 @@ package com.swmansion.enriched.textinput.styles import android.text.Editable +import android.text.Layout import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.AlignmentSpan import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan @@ -11,8 +13,9 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputOrderedListSpan 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.getParagraphRangesInRange import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries -import com.swmansion.enriched.textinput.utils.removeZWS +import com.swmansion.enriched.textinput.utils.removeNonAlignmentZWS class ListStyles( private val view: EnrichedTextInputView, @@ -78,7 +81,6 @@ class ListStyles( val span = EnrichedInputCheckboxListSpan(isChecked ?: false, view.htmlStyle) spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - // Invalidate layout to update checkbox drawing in case checkbox is bigger than line height view.layoutManager.invalidateLayout() } } @@ -98,10 +100,62 @@ class ListStyles( ssb.removeSpan(span) } - ssb.removeZWS(start, end) + ssb.removeNonAlignmentZWS(start, end) return true } + private fun reapplyTypingAlignmentAfterListRemoval( + spannable: Spannable, + start: Int, + end: Int, + ) { + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) + val cursorPos = view.selectionStart.coerceIn(0, spannable.length) + + // Selection changes during list removal can temporarily land in an empty paragraph + // before alignment spans/placeholders are applied there, which would sync typingAlignment + // back to ALIGN_NORMAL. Re-sync from surrounding spans at the final cursor anchor first. + if (!view.isDuringTransaction) { + view.syncTypingAlignmentWithSelection(cursorPos, cursorPos) + } + + val paragraphRanges = + if (safeStart == safeEnd) { + val (paragraphStart, paragraphEnd) = spannable.getParagraphBounds(safeStart, safeStart) + listOf(Pair(paragraphStart, paragraphEnd)) + } else { + spannable.getParagraphRangesInRange(safeStart, safeEnd) + } + + val rangesToProcess = + if (paragraphRanges.isEmpty()) { + val anchor = safeStart.coerceIn(0, spannable.length) + val (paragraphStart, paragraphEnd) = spannable.getParagraphBounds(anchor, anchor) + listOf(Pair(paragraphStart, paragraphEnd)) + } else { + paragraphRanges + } + + // Process in reverse so ZWS insertions don't shift earlier ranges + var insertedBeforeCursor = 0 + for ((paragraphStart, paragraphEnd) in rangesToProcess.reversed()) { + val inserted = view.applyTypingAlignmentToParagraphRange( + paragraphStart, + paragraphEnd, + manageCursorExternally = true, + ) + if (inserted && paragraphStart <= cursorPos) { + insertedBeforeCursor++ + } + } + + val finalCursor = (cursorPos + insertedBeforeCursor).coerceIn(0, spannable.length) + view.setSelection(finalCursor) + view.layoutManager.invalidateLayout() + view.requestLayout() + view.invalidate() + } + fun updateOrderedListIndexes( text: Spannable, position: Int, @@ -127,17 +181,23 @@ class ListStyles( if (styleStart != null) { view.spanState.setStart(name, null) - removeSpansForRange(spannable, start, end, config.clazz) + view.runAsATransaction { + removeSpansForRange(spannable, start, end, config.clazz) + reapplyTypingAlignmentAfterListRemoval(spannable, start, end) + } view.selection.validateStyles() return } + val alignment = captureAlignment(spannable, start, end) + if (start == end) { spannable.insert(start, EnrichedConstants.ZWS_STRING) view.spanState?.setStart(name, start + 1) removeSpansForRange(spannable, start, end, config.clazz) setSpan(spannable, name, start, end + 1, checkboxState) + applyAlignmentToRange(spannable, start, end + 1, alignment) return } @@ -150,6 +210,7 @@ class ListStyles( spannable.insert(currentStart, EnrichedConstants.ZWS_STRING) val currentEnd = currentStart + paragraph.length + 1 setSpan(spannable, name, currentStart, currentEnd, checkboxState) + applyAlignmentToRange(spannable, currentStart, currentEnd, alignment) currentStart = currentEnd + 1 } @@ -157,6 +218,37 @@ class ListStyles( view.spanState?.setStart(name, currentStart) } + private fun captureAlignment( + spannable: Spannable, + start: Int, + end: Int, + ): Layout.Alignment { + val alignmentSpans = spannable.getSpans(start, end, AlignmentSpan::class.java) + return alignmentSpans.firstOrNull()?.alignment ?: Layout.Alignment.ALIGN_NORMAL + } + + private fun applyAlignmentToRange( + spannable: Spannable, + start: Int, + end: Int, + alignment: Layout.Alignment, + ) { + if (alignment == Layout.Alignment.ALIGN_NORMAL) return + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) + if (safeStart >= safeEnd) return + + val existing = spannable.getSpans(safeStart, safeEnd, AlignmentSpan::class.java) + for (span in existing) { + spannable.removeSpan(span) + } + spannable.setSpan( + AlignmentSpan.Standard(alignment), + safeStart, + safeEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + fun toggleStyle(name: String) { toggleStyle(name, false) } @@ -182,7 +274,11 @@ class ListStyles( // Remove spans if cursor is at the start of the paragraph and spans exist if (isBackspace && start == cursorPosition && spans.isNotEmpty()) { - removeSpansForRange(s, start, end, config.clazz) + view.runAsATransaction { + removeSpansForRange(s, start, end, config.clazz) + val cursorAfterRemoval = view.selectionStart.coerceIn(0, s.length) + reapplyTypingAlignmentAfterListRemoval(s, cursorAfterRemoval, cursorAfterRemoval) + } return } @@ -195,6 +291,13 @@ class ListStyles( } if (!isBackspace && isNewLine && isPreviousParagraphList(s, start, config.clazz)) { + val prevParagraphAlignment = if (start > 0) { + val (prevStart, prevEnd) = s.getParagraphBounds(start - 1) + captureAlignment(s, prevStart, prevEnd) + } else { + Layout.Alignment.ALIGN_NORMAL + } + // Check if the span from the previous line "leaked" into this one if (spans.isNotEmpty()) { val existingSpan = spans[0] @@ -210,6 +313,7 @@ class ListStyles( s.insert(cursorPosition, EnrichedConstants.ZWS_STRING) setSpan(s, name, start, end + 1) + applyAlignmentToRange(s, start, end + 1, prevParagraphAlignment) // Inform that new span has been added view.selection?.validateStyles() return @@ -219,23 +323,28 @@ class ListStyles( if (spans.isNotEmpty()) { val previousSpan = spans[0] as EnrichedInputCheckboxListSpan val isChecked = previousSpan.isChecked + val alignment = captureAlignment(s, start, end) for (span in spans) { s.removeSpan(span) } setSpan(s, EnrichedSpans.CHECKBOX_LIST, start, end, isChecked) + applyAlignmentToRange(s, start, end, alignment) } return } if (spans.isNotEmpty()) { + val alignment = captureAlignment(s, start, end) + for (span in spans) { s.removeSpan(span) } setSpan(s, name, start, end) + applyAlignmentToRange(s, start, end, alignment) } } @@ -257,7 +366,14 @@ class ListStyles( end: Int, ): Boolean { val config = EnrichedSpans.listSpans[name] ?: return false - val spannable = view.text as Spannable - return removeSpansForRange(spannable, start, end, config.clazz) + val spannable = view.text as? Spannable ?: return false + var removed = false + view.runAsATransaction { + removed = removeSpansForRange(spannable, start, end, config.clazz) + if (removed) { + reapplyTypingAlignmentAfterListRemoval(spannable, start, end) + } + } + return removed } } 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..6d6a7174c 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 @@ -55,6 +55,9 @@ class EnrichedSelection( start = finalStart end = finalEnd validateStyles() + if (!view.isDuringTransaction) { + view.syncTypingAlignmentWithSelection(finalStart, finalEnd) + } emitSelectionChangeEvent(view.text, 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..5037b7446 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 @@ -246,11 +246,12 @@ 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", view.getCurrentAlignment()) return payload } - private fun emitStateChangeEvent() { + internal fun emitStateChangeEvent() { val context = view.context as ReactContext val surfaceId = UIManagerHelper.getSurfaceId(context) val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt index 1433e78de..0edb29688 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt @@ -43,6 +43,27 @@ fun Spannable.getParagraphBounds( fun Spannable.getParagraphBounds(index: Int): Pair = this.getParagraphBounds(index, index) +/** + * Returns separate paragraph ranges within the given range (inclusive). + * Mirrors iOS ParagraphsUtils.getSeparateParagraphsRangesIn:range: for alignment and list handling. + */ +fun Spannable.getParagraphRangesInRange(start: Int, end: Int): List> { + val (safeStart, safeEnd) = getSafeSpanBoundaries(start, end) + if (safeStart >= length) return emptyList() + + val result = mutableListOf>() + var position = safeStart + + while (position <= safeEnd && position < length) { + val (paragraphStart, paragraphEnd) = getParagraphBounds(position, position) + if (paragraphStart >= safeEnd) break + result.add(Pair(paragraphStart, paragraphEnd.coerceAtMost(length))) + position = paragraphEnd + 1 + } + + return result +} + fun Spannable.mergeSpannables( start: Int, end: Int, 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..c4e881daf 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,7 @@ package com.swmansion.enriched.textinput.utils import android.text.SpannableStringBuilder import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.textinput.spans.EnrichedAlignmentPlaceholderSpan fun CharSequence.zwsCountBefore(index: Int): Int { var count = 0 @@ -22,3 +23,21 @@ fun SpannableStringBuilder.removeZWS( } } } + +/** + * Removes ZWS characters in the range that are NOT anchoring an [EnrichedAlignmentPlaceholderSpan]. + * This preserves alignment placeholder ZWS while cleaning up list-related ZWS. + */ +fun SpannableStringBuilder.removeNonAlignmentZWS( + start: Int, + end: Int, +) { + for (i in (end - 1) downTo start) { + if (i >= length) continue + if (this[i] != EnrichedConstants.ZWS) continue + val placeholders = getSpans(i, i + 1, EnrichedAlignmentPlaceholderSpan::class.java) + if (placeholders.isEmpty()) { + delete(i, i + 1) + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedSpanWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedSpanWatcher.kt index 33f56a12b..431b7333f 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedSpanWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedSpanWatcher.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.textinput.watchers import android.text.SpanWatcher import android.text.Spannable +import android.text.style.AlignmentSpan import android.text.style.ParagraphStyle import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper @@ -85,8 +86,8 @@ class EnrichedSpanWatcher( // Do not parse spannable and emit event if onChangeHtml is not provided if (!view.shouldEmitHtml) return - // Emit event only if we change one of ours spans - if (what != null && what !is EnrichedInputSpan) return + // Emit event when our spans change or when paragraph alignment changes. + if (what != null && what !is EnrichedInputSpan && what !is AlignmentSpan) return val html = EnrichedParser.toHtml(s) if (html == previousHtml) return 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..2b6b6f71b 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,13 @@ 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.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.events.OnChangeTextEvent +import com.swmansion.enriched.textinput.spans.EnrichedAlignmentPlaceholderSpan class EnrichedTextWatcher( private val view: EnrichedTextInputView, @@ -13,6 +15,7 @@ class EnrichedTextWatcher( private var endCursorPosition: Int = 0 private var startCursorPosition: Int = 0 private var previousTextLength: Int = 0 + private var deletedAlignmentPlaceholder: Boolean = false override fun beforeTextChanged( s: CharSequence?, @@ -21,6 +24,11 @@ class EnrichedTextWatcher( after: Int, ) { previousTextLength = s?.length ?: 0 + deletedAlignmentPlaceholder = false + if (count > 0 && s is Spannable) { + val placeholders = s.getSpans(start, start + count, EnrichedAlignmentPlaceholderSpan::class.java) + deletedAlignmentPlaceholder = placeholders.isNotEmpty() + } } override fun onTextChanged( @@ -41,6 +49,7 @@ class EnrichedTextWatcher( if (view.isDuringTransaction) return applyStyles(s) + view.applyTypingAlignmentIfNeeded(s, startCursorPosition, endCursorPosition, previousTextLength, deletedAlignmentPlaceholder) } private fun applyStyles(s: Editable) { diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index af2328d86..41c2c9c14 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -2064,7 +2064,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c12d2108050e27952983d565a232f6f7b1ad5e69 - hermes-engine: 57506c1404e8b80c5386de18743e0d02c8d4f681 + hermes-engine: 500ff256d14bff88530c537966595a790099cd80 RCTDeprecation: 3280799c14232a56e5a44f92981a8ee33bc69fd9 RCTRequired: 9854a51b0f65ccf43ea0b744df4d70fce339db32 RCTSwiftUI: 96986e49a4fdc2c2103929dee2641e1b57edf33d @@ -2073,7 +2073,7 @@ SPEC CHECKSUMS: React: 7ef36630d07638043a134a7dd2ec17e0be10fc3c React-callinvoker: af4e8fe1d60ab63dd8d74c2a68988064c2848954 React-Core: c0fb1df65eb0ed7a8633841831f05f93c3eb3aff - React-Core-prebuilt: cd92350bf2041dde22a9bc0b8984d9c70d179ca1 + React-Core-prebuilt: 54f9f16cbdfa6704be637974582612ced01b2993 React-CoreModules: 7dfe7962360355f1547c85ab52e1fc4b57f17127 React-cxxreact: 9e9c7f1710bc58abebf924813b5e825b99adb8e5 React-debug: 38389b86e3570558ec73dd4cbc0cd2f2eec47a51 @@ -2135,7 +2135,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 625d2f6d9d5ef01acc9dfe2b5385504bbffd2ad0 ReactCodegen: 27937747ddc743fcb66a8dc19e8edf60188d94cc ReactCommon: cc0e38600f82487c5fe5d29150abb6fa9d981986 - ReactNativeDependencies: cebf665879bab2908201494cc5a9760dbdf0a637 + ReactNativeDependencies: 45dcc4fab8d93f8b7eddc8efe34c08f304c6104d ReactNativeEnriched: 4269ef1190b05845dffbed287c472df4e7976077 Yoga: 772166513f9cd2d61a6251d0dacbbfaa5b537479 diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index f4801c0ac..ce6e15e4e 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -85,6 +85,18 @@ const STYLE_ITEMS = [ name: 'checkbox-list', icon: 'check-square-o', }, + { + name: 'align-left', + icon: 'align-left', + }, + { + name: 'align-center', + icon: 'align-center', + }, + { + name: 'align-right', + icon: 'align-right', + }, ] as const; type Item = (typeof STYLE_ITEMS)[number]; @@ -168,6 +180,15 @@ export const Toolbar: FC = ({ case 'mention': editorRef.current?.startMention('@'); break; + case 'align-left': + editorRef.current?.setTextAlignment('left'); + break; + case 'align-center': + editorRef.current?.setTextAlignment('center'); + break; + case 'align-right': + editorRef.current?.setTextAlignment('right'); + break; } }; @@ -256,6 +277,12 @@ export const Toolbar: FC = ({ return stylesState.mention.isActive; case 'checkbox-list': return stylesState.checkboxList.isActive; + case 'align-left': + return stylesState.alignment === 'left'; + case 'align-center': + return stylesState.alignment === 'center'; + case 'align-right': + return stylesState.alignment === 'right'; default: return false; } diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts index f5170a576..feca136ce 100644 --- a/apps/example/src/constants/editorConfig.ts +++ b/apps/example/src/constants/editorConfig.ts @@ -28,6 +28,7 @@ export const DEFAULT_STYLES: StylesState = { image: DEFAULT_STYLE_STATE, mention: DEFAULT_STYLE_STATE, checkboxList: DEFAULT_STYLE_STATE, + alignment: 'left', }; export const DEFAULT_LINK_STATE = { diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index 7c9b9a5c5..982c1f35c 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -184,4 +184,14 @@ const styles = StyleSheet.create({ height: 1000, backgroundColor: 'rgb(0, 26, 114)', }, + alignmentLabel: { + marginTop: 20, + fontSize: 18, + fontWeight: 'bold', + textAlign: 'center', + color: 'rgb(0, 26, 114)', + }, + alignmentButton: { + width: '25%', + }, }); diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 410b456cf..34b776234 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -53,7 +53,7 @@ interface ContextMenuItem { | Type | Default Value | Platform | | ------------------- | ------------- | -------- | -| `ContextMenuItem[]` | [] | iOS | +| `ContextMenuItem[]` | [] | iOS | > [!NOTE] > On iOS items appear in array order, before the system items (Copy/Paste/Cut). @@ -110,7 +110,7 @@ With this approach you can customize what patterns should be recognized as links Keep in mind that not all JS regex features are supported, for example variable-width lookbehinds won't work. | Type | Default Value | Platform | -|----------|-------------------------------|----------| +| -------- | ----------------------------- | -------- | | `RegExp` | default native platform regex | Both | > [!TIP] @@ -121,7 +121,7 @@ Keep in mind that not all JS regex features are supported, for example variable- Callback that's called whenever the input loses focused (is blurred). | Type | Platform | -|--------------|----------| +| ------------ | -------- | | `() => void` | Both | ### `onChangeHtml` @@ -139,7 +139,7 @@ interface OnChangeHtmlEvent { - `value` is the new HTML. | Type | Platform | -|------------------------------------------------------------|----------| +| ---------------------------------------------------------- | -------- | | `(event: NativeSyntheticEvent) => void` | Both | > [!TIP] @@ -164,7 +164,7 @@ interface OnChangeMentionEvent { - `text` contains whole text that has been typed after the indicator. | Type | Platform | -|-----------------------------------------|----------| +| --------------------------------------- | -------- | | `(event: OnChangeMentionEvent) => void` | Both | ### `onChangeSelection` @@ -186,7 +186,7 @@ interface OnChangeSelectionEvent { - `text` is the input's text in the current selection. | Type | Platform | -|-----------------------------------------------------------------|----------| +| --------------------------------------------------------------- | -------- | | `(event: NativeSyntheticEvent) => void` | Both | ### `onChangeState` @@ -292,15 +292,17 @@ interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; } ``` - `isActive` indicates if the style is active within current selection. - `isBlocking` indicates if the style is blocked by other currently active, meaning it can't be toggled. - `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'`. | Type | Platform | -|-------------------------------------------------------------|----------| +| ----------------------------------------------------------- | -------- | | `(event: NativeSyntheticEvent) => void` | Both | ### `onChangeText` @@ -318,7 +320,7 @@ interface OnChangeTextEvent { - `value` is the new text value of the input. | Type | Platform | -|------------------------------------------------------------|----------| +| ---------------------------------------------------------- | -------- | | `(event: NativeSyntheticEvent) => void` | Both | > [!TIP] @@ -331,7 +333,7 @@ Callback that is called when the user no longer edits a mention actively - has m - `indicator` is the indicator of the mention that was being edited. | Type | Platform | -|-------------------------------|----------| +| ----------------------------- | -------- | | `(indicator: string) => void` | Both | ### `onFocus` @@ -339,7 +341,7 @@ Callback that is called when the user no longer edits a mention actively - has m Callback that's called whenever the input is focused. | Type | Platform | -|--------------|----------| +| ------------ | -------- | | `() => void` | Both | ### `onLinkDetected` @@ -363,7 +365,7 @@ interface OnLinkDetected { - `end` is the first index after the ending index of the link. | Type | Platform | -|-----------------------------------|----------| +| --------------------------------- | -------- | | `(event: OnLinkDetected) => void` | Both | ### `onMentionDetected` @@ -385,7 +387,7 @@ interface OnMentionDetected { - `attributes` are the additional user-defined attributes that are being stored with the mention. | Type | Platform | -|--------------------------------------|----------| +| ------------------------------------ | -------- | | `(event: OnMentionDetected) => void` | Both | ### `onStartMention` @@ -395,7 +397,7 @@ Callback that gets called whenever a mention editing starts (after placing the i - `indicator` is the indicator of the mention that begins editing. | Type | Platform | -|-------------------------------|----------| +| ----------------------------- | -------- | | `(indicator: string) => void` | Both | ### `onKeyPress` @@ -409,7 +411,7 @@ export interface OnKeyPressEvent { ``` | Type | Platform | -|----------------------------------------------------------|----------| +| -------------------------------------------------------- | -------- | | `(event: NativeSyntheticEvent) => void` | Both | ### `OnPasteImages` @@ -500,7 +502,7 @@ If true, Android will use experimental synchronous events. This will prevent fro If true, external HTML pasted/inserted into the input (e.g. from Google Docs, Word, or web pages) will be normalized into the canonical tag subset that the enriched parser understands. However, this is an experimental feature, which has not been thoroughly tested. We may decide to enable it by default in a future release. | Type | Default Value | Platform | -| ------ | ------------- |----------| +| ------ | ------------- | -------- | | `bool` | `false` | Both | ## Ref Methods @@ -614,6 +616,21 @@ Sets the selection at the given indexes. - `start: number` - starting index of the selection. - `end: number` - first index after the selection's ending index. For just a cursor in place (no selection), `start` equals `end`. +### `.setTextAlignment()` + +```ts +setTextAlignment: ( + alignment: 'left' | 'center' | 'right' | 'justify' | 'default' +) => void; +``` + +Sets the text alignment for the current selection or paragraph. + +- `alignment: 'left' | 'center' | 'right' | 'justify' | 'default'` - the alignment to apply. + +> [!NOTE] +> Text justification (`justify`) is currently not supported on Android and will fallback to `default` alignment. + ### `.startMention()` ```ts diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 5e81c9f26..888922fc5 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,8 +1,10 @@ #import "EnrichedTextInputView.h" +#import "AlignmentUtils.h" #import "CoreText/CoreText.h" #import "ImageAttachment.h" #import "LayoutManagerExtension.h" #import "ParagraphAttributesUtils.h" +#import "ParagraphsUtils.h" #import "RCTFabricComponentsPlugins.h" #import "StringExtension.h" #import "StyleHeaders.h" @@ -51,6 +53,7 @@ @implementation EnrichedTextInputView { BOOL _emitTextChange; NSMutableDictionary *_attachmentViews; NSArray *_contextMenuItems; + NSString *_recentlyEmittedAlignment; } // MARK: - Component utils @@ -89,6 +92,7 @@ - (void)setDefaults { _blockedStyles = [[NSMutableSet alloc] init]; _recentlyActiveLinkRange = NSMakeRange(0, 0); _recentlyActiveMentionRange = NSMakeRange(0, 0); + _recentlyEmittedAlignment = @"left"; recentlyChangedRange = NSMakeRange(0, 0); _recentInputString = @""; _recentlyEmittedHtml = @"\n

\n"; @@ -1121,12 +1125,20 @@ - (void)tryUpdatingActiveStyles { } } + // detect alignment change + NSString *currentAlignment = + [AlignmentUtils currentAlignmentStringForInput:self]; + if (![currentAlignment isEqualToString:_recentlyEmittedAlignment]) { + updateNeeded = YES; + } + if (updateNeeded) { auto emitter = [self getEventEmitter]; if (emitter != nullptr) { // update activeStyles and blockedStyles only if emitter is available _activeStyles = newActiveStyles; _blockedStyles = newBlockedStyles; + _recentlyEmittedAlignment = currentAlignment; emitter->onChangeState( {.bold = GET_STYLE_STATE([BoldStyle getStyleType]), @@ -1147,7 +1159,8 @@ - (void)tryUpdatingActiveStyles { .blockQuote = GET_STYLE_STATE([BlockQuoteStyle getStyleType]), .codeBlock = GET_STYLE_STATE([CodeBlockStyle getStyleType]), .image = GET_STYLE_STATE([ImageStyle getStyleType]), - .checkboxList = GET_STYLE_STATE([CheckboxListStyle getStyleType])}); + .checkboxList = GET_STYLE_STATE([CheckboxListStyle getStyleType]), + .alignment = [currentAlignment UTF8String]}); } } @@ -1282,6 +1295,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { } else if ([commandName isEqualToString:@"requestHTML"]) { NSInteger requestId = [((NSNumber *)args[0]) integerValue]; [self requestHTML:requestId]; + } else if ([commandName isEqualToString:@"setTextAlignment"]) { + NSString *alignmentString = (NSString *)args[0]; + [AlignmentUtils applyAlignmentFromString:alignmentString toInput:self]; } } diff --git a/ios/extensions/LayoutManagerExtension.mm b/ios/extensions/LayoutManagerExtension.mm index acce99f8d..89324fac7 100644 --- a/ios/extensions/LayoutManagerExtension.mm +++ b/ios/extensions/LayoutManagerExtension.mm @@ -267,6 +267,7 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput NSForegroundColorAttributeName : [typedInput->config orderedListMarkerColor] }; + CGFloat indent = pStyle.firstLineHeadIndent; NSArray *paragraphs = [ParagraphsUtils getSeparateParagraphsRangesIn:typedInput->textView @@ -308,18 +309,21 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput marker:marker markerAttributes:markerAttributes origin:origin - usedRect:textUsedRect]; + usedRect:textUsedRect + indent:indent]; } else if (markerFormat == NSTextListMarkerDisc) { [self drawBullet:typedInput origin:origin - usedRect:textUsedRect]; + usedRect:textUsedRect + indent:indent]; } else if ([markerFormat hasPrefix:@"{checkbox"]) { [self drawCheckbox:typedInput markerFormat:markerFormat origin:origin - usedRect:textUsedRect]; + usedRect:textUsedRect + indent:indent]; } // only first line of a list gets its // marker drawn @@ -387,7 +391,8 @@ - (CGRect)getTextAlignedUsedRect:(CGRect)usedRect font:(UIFont *)font { - (void)drawCheckbox:(EnrichedTextInputView *)typedInput markerFormat:(NSString *)markerFormat origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { BOOL isChecked = [markerFormat isEqualToString:@"{checkbox:1}"]; UIImage *image = isChecked ? typedInput->config.checkboxCheckedImage @@ -396,7 +401,7 @@ - (void)drawCheckbox:(EnrichedTextInputView *)typedInput CGFloat boxSize = [typedInput->config checkboxListBoxSize]; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; - CGFloat boxX = origin.x + usedRect.origin.x - gapWidth - boxSize; + CGFloat boxX = origin.x + indent - gapWidth - boxSize; CGFloat boxY = centerY - boxSize / 2.0; [image drawAtPoint:CGPointMake(boxX, boxY)]; @@ -404,10 +409,11 @@ - (void)drawCheckbox:(EnrichedTextInputView *)typedInput - (void)drawBullet:(EnrichedTextInputView *)typedInput origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { CGFloat gapWidth = [typedInput->config unorderedListGapWidth]; CGFloat bulletSize = [typedInput->config unorderedListBulletSize]; - CGFloat bulletX = origin.x + usedRect.origin.x - gapWidth - bulletSize / 2; + CGFloat bulletX = origin.x + indent - gapWidth - bulletSize / 2; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; CGContextRef context = UIGraphicsGetCurrentContext(); @@ -425,10 +431,11 @@ - (void)drawDecimal:(EnrichedTextInputView *)typedInput marker:(NSString *)marker markerAttributes:(NSDictionary *)markerAttributes origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { CGFloat gapWidth = [typedInput->config orderedListGapWidth]; CGSize markerSize = [marker sizeWithAttributes:markerAttributes]; - CGFloat markerX = usedRect.origin.x - gapWidth - markerSize.width / 2; + CGFloat markerX = origin.x + indent - gapWidth - markerSize.width / 2; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; CGFloat markerY = centerY - markerSize.height / 2.0; diff --git a/ios/inputParser/InputParser.mm b/ios/inputParser/InputParser.mm index 51edf3cf2..410bb19e3 100644 --- a/ios/inputParser/InputParser.mm +++ b/ios/inputParser/InputParser.mm @@ -1,4 +1,6 @@ #import "InputParser.h" +#import "AlignmentEntry.h" +#import "AlignmentUtils.h" #import "EnrichedTextInputView.h" #import "StringExtension.h" #import "StyleHeaders.h" @@ -219,19 +221,24 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { [result appendString:@"\n"]; } + NSString *styleAttr = [self prepareStyleAttrStr:currentRange.location + isOpeningTag:YES]; + // handle starting unordered list if (!inUnorderedList && [currentActiveStyles containsObject:@([UnorderedListStyle getStyleType])]) { inUnorderedList = YES; - [result appendString:@"\n
    "]; + [result + appendString:[NSString stringWithFormat:@"\n", styleAttr]]; } // handle starting ordered list if (!inOrderedList && [currentActiveStyles containsObject:@([OrderedListStyle getStyleType])]) { inOrderedList = YES; - [result appendString:@"\n
      "]; + [result + appendString:[NSString stringWithFormat:@"\n", styleAttr]]; } // handle starting blockquotes if (!inBlockQuote && @@ -252,7 +259,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { [currentActiveStyles containsObject:@([CheckboxListStyle getStyleType])]) { inCheckboxList = YES; - [result appendString:@"\n
        "]; + [result appendString:[NSString stringWithFormat: + @"\n
          ", + styleAttr]]; } // don't add the

          tag if some paragraph styles are present @@ -274,7 +283,7 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { containsObject:@([CheckboxListStyle getStyleType])]) { [result appendString:@"\n"]; } else { - [result appendString:@"\n

          "]; + [result appendString:[NSString stringWithFormat:@"", styleAttr]]; } } @@ -486,6 +495,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { - (NSString *)tagContentForStyle:(NSNumber *)style openingTag:(BOOL)openingTag location:(NSInteger)location { + NSString *styleAttr = [self prepareStyleAttrStr:location + isOpeningTag:openingTag]; + if ([style isEqualToNumber:@([BoldStyle getStyleType])]) { return @"b"; } else if ([style isEqualToNumber:@([ItalicStyle getStyleType])]) { @@ -565,17 +577,17 @@ - (NSString *)tagContentForStyle:(NSNumber *)style return @"mention"; } } else if ([style isEqualToNumber:@([H1Style getStyleType])]) { - return @"h1"; + return [NSString stringWithFormat:@"h1%@", styleAttr]; } else if ([style isEqualToNumber:@([H2Style getStyleType])]) { - return @"h2"; + return [NSString stringWithFormat:@"h2%@", styleAttr]; } else if ([style isEqualToNumber:@([H3Style getStyleType])]) { - return @"h3"; + return [NSString stringWithFormat:@"h3%@", styleAttr]; } else if ([style isEqualToNumber:@([H4Style getStyleType])]) { - return @"h4"; + return [NSString stringWithFormat:@"h4%@", styleAttr]; } else if ([style isEqualToNumber:@([H5Style getStyleType])]) { - return @"h5"; + return [NSString stringWithFormat:@"h5%@", styleAttr]; } else if ([style isEqualToNumber:@([H6Style getStyleType])]) { - return @"h6"; + return [NSString stringWithFormat:@"h6%@", styleAttr]; } else if ([style isEqualToNumber:@([UnorderedListStyle getStyleType])] || [style isEqualToNumber:@([OrderedListStyle getStyleType])]) { return @"li"; @@ -595,8 +607,7 @@ - (NSString *)tagContentForStyle:(NSNumber *)style } } else if ([style isEqualToNumber:@([BlockQuoteStyle getStyleType])] || [style isEqualToNumber:@([CodeBlockStyle getStyleType])]) { - // blockquotes and codeblock use

          tags the same way lists use

        • - return @"p"; + return [NSString stringWithFormat:@"p%@", styleAttr]; } return @""; } @@ -605,6 +616,7 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { NSArray *processingResult = [self getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; + NSArray *alignments = (NSArray *)processingResult[2]; // reset the text first and reset typing attributes _input->textView.text = @""; @@ -617,12 +629,14 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { [self applyProcessedStyles:stylesInfo offsetFromBeginning:0 plainTextLength:plainText.length]; + [self applyAlignments:alignments offset:0]; } - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { NSArray *processingResult = [self getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; + NSArray *alignments = (NSArray *)processingResult[2]; // we can use ready replace util [TextInsertionUtils replaceText:plainText @@ -634,12 +648,14 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { [self applyProcessedStyles:stylesInfo offsetFromBeginning:range.location plainTextLength:plainText.length]; + [self applyAlignments:alignments offset:range.location]; } - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { NSArray *processingResult = [self getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; + NSArray *alignments = (NSArray *)processingResult[2]; // same here, insertion utils got our back [TextInsertionUtils insertText:plainText @@ -651,6 +667,7 @@ - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { [self applyProcessedStyles:stylesInfo offsetFromBeginning:location plainTextLength:plainText.length]; + [self applyAlignments:alignments offset:location]; } - (void)applyProcessedStyles:(NSArray *)processedStyles @@ -1098,6 +1115,8 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { NSMutableDictionary *ongoingTags = [[NSMutableDictionary alloc] init]; NSMutableArray *initiallyProcessedTags = [[NSMutableArray alloc] init]; NSMutableDictionary *checkboxStates = [[NSMutableDictionary alloc] init]; + NSMutableArray *foundAlignments = + [[NSMutableArray alloc] init]; BOOL insideCheckboxList = NO; _precedingImageCount = 0; BOOL insideTag = NO; @@ -1138,8 +1157,7 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { isSelfClosing = YES; } - if ([currentTagName isEqualToString:@"p"] || - [currentTagName isEqualToString:@"br"]) { + if ([currentTagName isEqualToString:@"br"]) { // do nothing, we don't include these tags in styles } else if ([currentTagName isEqualToString:@"li"]) { // Only track checkbox state if we're inside a checkbox list @@ -1148,6 +1166,13 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { checkboxStates[@(plainText.length)] = @(isChecked); } } else if (!closingTag) { + BOOL isPlainParagraph = + [currentTagName isEqualToString:@"p"] && + (!currentTagParams || [currentTagParams length] == 0); + + if (isPlainParagraph) { + continue; + } // we finish opening tag - get its location and optionally params and // put them under tag name key in ongoingTags NSMutableArray *tagArr = [[NSMutableArray alloc] init]; @@ -1199,6 +1224,9 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { mutableCopy]; } + [self checkForAlignments:ongoingTags[currentTagName] + plainText:plainText + foundAlignments:foundAlignments]; [self finalizeTagEntry:currentTagName ongoingTags:ongoingTags initiallyProcessedTags:initiallyProcessedTags @@ -1422,7 +1450,7 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { [processedStyles addObject:styleArr]; } - return @[ plainText, processedStyles ]; + return @[ plainText, processedStyles, foundAlignments ]; } - (BOOL)isUlCheckboxList:(NSString *)params { @@ -1445,4 +1473,110 @@ - (NSDictionary *)prepareCheckboxListStyleValue:(NSValue *)rangeValue return statesInRange; } +- (NSString *)cssValueForAlignment:(NSTextAlignment)alignment { + switch (alignment) { + case NSTextAlignmentCenter: + return @"center"; + case NSTextAlignmentRight: + return @"right"; + case NSTextAlignmentJustified: + return @"justify"; + default: + return nil; + } +} + +- (NSString *)prepareStyleAttrStr:(NSInteger)location + isOpeningTag:(BOOL)isOpeningTag { + if (!isOpeningTag) { + return @""; + } + + NSParagraphStyle *pStyle = + [_input->textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:location + effectiveRange:nil]; + NSString *alignStr = [self cssValueForAlignment:pStyle.alignment]; + + if (alignStr) { + return [NSString stringWithFormat:@" style=\"text-align: %@\"", alignStr]; + } + + return @""; +} + +- (NSTextAlignment)alignmentFromStyleParams:(NSString *)params { + if (!params) + return NSTextAlignmentNatural; + + NSString *pattern = @"text-align\\s*:\\s*(left|center|right|justify)"; + + NSRegularExpression *regex = [NSRegularExpression + regularExpressionWithPattern:pattern + options:NSRegularExpressionCaseInsensitive + error:nil]; + + NSTextCheckingResult *match = + [regex firstMatchInString:params + options:0 + range:NSMakeRange(0, params.length)]; + + if (match) { + // rangeAtIndex:1 corresponds to the capture group + // (left|center|right|justify) + NSString *value = + [[params substringWithRange:[match rangeAtIndex:1]] lowercaseString]; + + if ([value isEqualToString:@"center"]) + return NSTextAlignmentCenter; + if ([value isEqualToString:@"right"]) + return NSTextAlignmentRight; + if ([value isEqualToString:@"justify"]) + return NSTextAlignmentJustified; + if ([value isEqualToString:@"left"]) + return NSTextAlignmentLeft; + } + + return NSTextAlignmentNatural; +} + +- (void)applyAlignments:(NSArray *)alignments + offset:(NSInteger)offset { + for (AlignmentEntry *entry in alignments) { + // Offset the range (e.g. if inserting into the middle of text) + NSRange finalRange = + NSMakeRange(offset + entry.range.location, entry.range.length); + + [AlignmentUtils setAlignment:entry.alignment + forRange:finalRange + inInput:_input]; + } +} + +- (void)checkForAlignments:(NSArray *)tagData + plainText:(NSString *)plainText + foundAlignments:(NSMutableArray *)foundAlignments { + if (tagData == nil) { + return; + } + + // We look at the params stored in ongoingTags + NSString *storedParams = (tagData.count > 1) ? tagData[1] : nil; + NSTextAlignment align = [self alignmentFromStyleParams:storedParams]; + + if (align != NSTextAlignmentNatural) { + NSInteger startLoc = [tagData[0] integerValue]; + // Calculate range relative to plainText + NSInteger actualStart = startLoc + _precedingImageCount; + NSInteger length = plainText.length - startLoc; + + if (length > 0) { + AlignmentEntry *entry = [[AlignmentEntry alloc] init]; + entry.alignment = align; + entry.range = NSMakeRange(actualStart, length); + [foundAlignments addObject:entry]; + } + } +} + @end diff --git a/ios/interfaces/AlignmentEntry.h b/ios/interfaces/AlignmentEntry.h new file mode 100644 index 000000000..129109462 --- /dev/null +++ b/ios/interfaces/AlignmentEntry.h @@ -0,0 +1,9 @@ +#pragma once +#import + +@interface AlignmentEntry : NSObject + +@property(nonatomic, assign) NSRange range; +@property(nonatomic, assign) NSTextAlignment alignment; + +@end diff --git a/ios/interfaces/AlignmentEntry.mm b/ios/interfaces/AlignmentEntry.mm new file mode 100644 index 000000000..160009bc7 --- /dev/null +++ b/ios/interfaces/AlignmentEntry.mm @@ -0,0 +1,4 @@ +#import "AlignmentEntry.h" + +@implementation AlignmentEntry +@end diff --git a/ios/utils/AlignmentUtils.h b/ios/utils/AlignmentUtils.h new file mode 100644 index 000000000..8e364f518 --- /dev/null +++ b/ios/utils/AlignmentUtils.h @@ -0,0 +1,17 @@ +#import "EnrichedTextInputView.h" +#import + +@interface AlignmentUtils : NSObject + ++ (void)applyAlignmentFromString:(NSString *)alignStr + toInput:(EnrichedTextInputView *)input; + ++ (void)setAlignment:(NSTextAlignment)alignment + forRange:(NSRange)range + inInput:(EnrichedTextInputView *)input; + ++ (NSString *)alignmentToString:(NSTextAlignment)alignment; + ++ (NSString *)currentAlignmentStringForInput:(EnrichedTextInputView *)input; + +@end diff --git a/ios/utils/AlignmentUtils.mm b/ios/utils/AlignmentUtils.mm new file mode 100644 index 000000000..90c0a4478 --- /dev/null +++ b/ios/utils/AlignmentUtils.mm @@ -0,0 +1,175 @@ +#import "AlignmentUtils.h" +#import "ParagraphsUtils.h" +#import "StyleHeaders.h" + +@implementation AlignmentUtils + ++ (void)applyAlignmentFromString:(NSString *)alignStr + toInput:(EnrichedTextInputView *)input { + NSTextAlignment alignment = NSTextAlignmentNatural; + + if ([alignStr isEqualToString:@"left"]) { + alignment = NSTextAlignmentLeft; + } else if ([alignStr isEqualToString:@"center"]) { + alignment = NSTextAlignmentCenter; + } else if ([alignStr isEqualToString:@"right"]) { + alignment = NSTextAlignmentRight; + } else if ([alignStr isEqualToString:@"justify"]) { + alignment = NSTextAlignmentJustified; + } + + [AlignmentUtils setAlignment:alignment + forRange:input->textView.selectedRange + inInput:input]; +} + ++ (void)setAlignment:(NSTextAlignment)alignment + forRange:(NSRange)forRange + inInput:(EnrichedTextInputView *)input { + UITextView *textView = input->textView; + // Expand the range if we are inside a List + NSRange targetRange = [AlignmentUtils expandRangeToContiguousList:forRange + inInput:input]; + NSArray *paragraphs = + [ParagraphsUtils getSeparateParagraphsRangesIn:textView + range:targetRange]; + + [textView.textStorage beginEditing]; + for (NSValue *val in paragraphs) { + NSRange pRange = [val rangeValue]; + [textView.textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:pRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + NSMutableParagraphStyle *style = + value ? [value mutableCopy] + : [[NSParagraphStyle defaultParagraphStyle] + mutableCopy]; + style.alignment = alignment; + + [textView.textStorage + addAttribute:NSParagraphStyleAttributeName + value:style + range:range]; + }]; + } + [textView.textStorage endEditing]; + + // Update Typing Attributes + NSMutableDictionary *typingAttrs = [textView.typingAttributes mutableCopy]; + NSMutableParagraphStyle *typingStyle = + [typingAttrs[NSParagraphStyleAttributeName] mutableCopy]; + typingStyle.alignment = alignment; + typingAttrs[NSParagraphStyleAttributeName] = typingStyle; + textView.typingAttributes = typingAttrs; + + [input anyTextMayHaveBeenModified]; +} + ++ (NSString *)alignmentToString:(NSTextAlignment)alignment { + switch (alignment) { + case NSTextAlignmentLeft: + return @"left"; + case NSTextAlignmentCenter: + return @"center"; + case NSTextAlignmentRight: + return @"right"; + case NSTextAlignmentJustified: + return @"justify"; + case NSTextAlignmentNatural: + default: + return @"left"; + } +} + ++ (NSString *)currentAlignmentStringForInput:(EnrichedTextInputView *)input { + NSParagraphStyle *paraStyle = + input->textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment alignment = + paraStyle ? paraStyle.alignment : NSTextAlignmentNatural; + return [AlignmentUtils alignmentToString:alignment]; +} + ++ (NSRange)expandRangeToContiguousList:(NSRange)range + inInput:(EnrichedTextInputView *)input { + NSString *text = input->textView.textStorage.string; + if (text.length == 0) + return range; + + NSArray *listStyles = @[ + input->stylesDict[@([UnorderedListStyle getStyleType])], + input->stylesDict[@([OrderedListStyle getStyleType])], + input->stylesDict[@([CheckboxListStyle getStyleType])] + ]; + + NSRange expandedRange = range; + + // Expand Backward + NSRange startParagraph = + [text paragraphRangeForRange:NSMakeRange(range.location, 0)]; + + // Find which list style is active at the start + id activeStartStyle = nil; + for (id style in listStyles) { + if ([style detectStyle:startParagraph]) { + activeStartStyle = style; + break; + } + } + + // If we found a list style, walk backwards until it stops + if (activeStartStyle) { + NSRange currentPara = startParagraph; + while (currentPara.location > 0) { + // Check the paragraph before the current one + NSRange prevPara = [text + paragraphRangeForRange:NSMakeRange(currentPara.location - 1, 0)]; + + if ([activeStartStyle detectStyle:prevPara]) { + // It's still the same list -> Expand our range. + expandedRange = NSUnionRange(expandedRange, prevPara); + currentPara = prevPara; + } else { + // The list ended here. + break; + } + } + } + + // Expand forward, we check the paragraph at the end of the current selection + NSUInteger endLoc = + (range.length > 0) ? (NSMaxRange(range) - 1) : range.location; + NSRange endParagraph = [text paragraphRangeForRange:NSMakeRange(endLoc, 0)]; + + // Find which list style is active at the end + id activeEndStyle = nil; + for (id style in listStyles) { + if ([style detectStyle:endParagraph]) { + activeEndStyle = style; + break; + } + } + + // If we found a list style, walk forwards until it stops + if (activeEndStyle) { + NSRange currentPara = endParagraph; + while (NSMaxRange(currentPara) < text.length) { + // Check the paragraph after the current one + NSRange nextPara = + [text paragraphRangeForRange:NSMakeRange(NSMaxRange(currentPara), 0)]; + + if ([activeEndStyle detectStyle:nextPara]) { + // It's still the same list -> expand our range. + expandedRange = NSUnionRange(expandedRange, nextPara); + currentPara = nextPara; + } else { + break; + } + } + } + + return expandedRange; +} + +@end diff --git a/ios/utils/ParagraphAttributesUtils.mm b/ios/utils/ParagraphAttributesUtils.mm index 9095bf4a5..d273789d0 100644 --- a/ios/utils/ParagraphAttributesUtils.mm +++ b/ios/utils/ParagraphAttributesUtils.mm @@ -53,6 +53,12 @@ + (BOOL)handleBackspaceInRange:(NSRange)range if (range.location == nonNewlineRange.location && range.length >= nonNewlineRange.length) { + // Preserve the paragraph alignment across typing attribute resets. + NSParagraphStyle *currentParaStyle = + typedInput->textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment savedAlignment = + currentParaStyle ? currentParaStyle.alignment : NSTextAlignmentNatural; + // for lists, quotes and codeblocks present we do the following: // - manually do the removing // - reset typing attribtues so that the previous line styles don't get @@ -71,8 +77,8 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = - typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput + preserveAlignment:savedAlignment]; [cbLStyle addAttributesWithCheckedValue:isCurrentlyChecked inRange:NSMakeRange(range.location, 0) withTypingAttr:YES]; @@ -82,8 +88,8 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = - typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput + preserveAlignment:savedAlignment]; [style addAttributes:NSMakeRange(range.location, 0) withTypingAttr:YES]; } @@ -99,7 +105,7 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput preserveAlignment:savedAlignment]; return YES; } @@ -249,19 +255,40 @@ + (BOOL)handleResetTypingAttributesOnBackspace:(NSRange)range } if (isLeftLineEmpty && isRightLineEmpty) { + NSParagraphStyle *currentParaStyle = + typedInput->textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment savedAlignment = + currentParaStyle ? currentParaStyle.alignment : NSTextAlignmentNatural; + [TextInsertionUtils replaceText:text at:range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput preserveAlignment:savedAlignment]; return YES; } return NO; } ++ (void)resetTypingAttributes:(EnrichedTextInputView *)input + preserveAlignment:(NSTextAlignment)alignment { + NSMutableDictionary *resetAttrs = + [input->defaultTypingAttributes mutableCopy]; + + if (alignment != NSTextAlignmentNatural) { + NSMutableParagraphStyle *paraStyle = + [resetAttrs[NSParagraphStyleAttributeName] mutableCopy] + ?: [[NSMutableParagraphStyle alloc] init]; + paraStyle.alignment = alignment; + resetAttrs[NSParagraphStyleAttributeName] = paraStyle; + } + + input->textView.typingAttributes = resetAttrs; +} + + (BOOL)isParagraphEmpty:(NSRange)range inString:(NSString *)string { if (range.length == 0) return YES; diff --git a/ios/utils/TextInsertionUtils.mm b/ios/utils/TextInsertionUtils.mm index 6afa0b876..f6b234ce1 100644 --- a/ios/utils/TextInsertionUtils.mm +++ b/ios/utils/TextInsertionUtils.mm @@ -22,6 +22,12 @@ + (void)insertText:(NSString *)text [copiedAttrs addEntriesFromDictionary:additionalAttrs]; } + // Give \u200B a tiny kern so the layout engine recognizes ZWS-only lines + // under right/center alignment (zero advance width causes height collapse). + if ([text rangeOfString:@"\u200B"].location != NSNotFound) { + copiedAttrs[NSKernAttributeName] = @(__FLT_EPSILON__); + } + NSAttributedString *newAttrStr = [[NSAttributedString alloc] initWithString:text attributes:copiedAttrs]; [textView.textStorage insertAttributedString:newAttrStr atIndex:index]; @@ -55,6 +61,16 @@ + (void)replaceText:(NSString *)text range:NSMakeRange(range.location, [text length])]; } + // Give \u200B a tiny kern so the layout engine recognizes ZWS-only lines + // under right/center alignment (zero advance width causes height collapse). + if ([text length] > 0 && + [text rangeOfString:@"\u200B"].location != NSNotFound) { + [textView.textStorage + addAttribute:NSKernAttributeName + value:@(__FLT_EPSILON__) + range:NSMakeRange(range.location, [text length])]; + } + if (withSelection) { if (![textView isFirstResponder]) { [textView reactFocus]; diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index ed6063eac..564b560c8 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -78,6 +78,9 @@ export interface EnrichedTextInputInstance extends NativeMethods { text: string, attributes?: Record ) => void; + setTextAlignment: ( + alignment: 'left' | 'center' | 'right' | 'justify' | 'default' + ) => void; } export interface ContextMenuItem { @@ -379,6 +382,11 @@ export const EnrichedTextInput = ({ setSelection: (start: number, end: number) => { Commands.setSelection(nullthrows(nativeRef.current), start, end); }, + setTextAlignment: ( + alignment: 'left' | 'center' | 'right' | 'justify' | 'default' + ) => { + Commands.setTextAlignment(nullthrows(nativeRef.current), alignment); + }, })); const handleMentionEvent = (e: NativeSyntheticEvent) => { diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 8bf5186aa..082f78f8a 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -122,6 +122,7 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; } export interface OnLinkDetected { @@ -268,6 +269,7 @@ export interface OnContextMenuItemPressEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; }; } @@ -460,6 +462,10 @@ interface NativeCommands { viewRef: React.ElementRef, requestId: Int32 ) => void; + setTextAlignment: ( + viewRef: React.ElementRef, + alignment: string + ) => void; } export const Commands: NativeCommands = codegenNativeCommands({ @@ -493,6 +499,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'startMention', 'addMention', 'requestHTML', + 'setTextAlignment', ], });