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 7038d21c9..66b6be628 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 42264b4ed..8a0c0fc3a 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -11,8 +11,12 @@ 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.SpannableString +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 @@ -48,6 +52,8 @@ import com.swmansion.enriched.textinput.events.OnInputBlurEvent import com.swmansion.enriched.textinput.events.OnInputFocusEvent import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent +import com.swmansion.enriched.textinput.spans.EnrichedAlignmentPlaceholderSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan import com.swmansion.enriched.textinput.spans.EnrichedInputH1Span import com.swmansion.enriched.textinput.spans.EnrichedInputH2Span import com.swmansion.enriched.textinput.spans.EnrichedInputH3Span @@ -56,6 +62,8 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputH5Span import com.swmansion.enriched.textinput.spans.EnrichedInputH6Span import com.swmansion.enriched.textinput.spans.EnrichedInputImageSpan import com.swmansion.enriched.textinput.spans.EnrichedInputLinkSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputOrderedListSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan @@ -68,6 +76,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 @@ -87,7 +98,9 @@ class EnrichedTextInputView : 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 @@ -122,6 +135,7 @@ class EnrichedTextInputView : 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() @@ -283,6 +297,14 @@ class EnrichedTextInputView : 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, @@ -728,6 +750,492 @@ class EnrichedTextInputView : ) } + 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 @@ -976,11 +1484,11 @@ class EnrichedTextInputView : // 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) } } @@ -1008,7 +1516,7 @@ class EnrichedTextInputView : 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 6b24ac27b..998689f6e 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -448,6 +448,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..8a9dff4fa 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,63 @@ 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 +182,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 +211,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 +219,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 +275,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 +292,14 @@ 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 +315,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 +325,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 +368,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 ebc5e83c2..7eed963b6 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,30 @@ 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/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 7450fe8e3..abcc9e649 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -187,4 +187,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 a84462ad6..dc5867cb3 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` @@ -499,7 +501,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 @@ -613,6 +615,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 66c22512b..51ec6c4c8 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,10 +1,12 @@ #import "EnrichedTextInputView.h" +#import "AlignmentUtils.h" #import "CoreText/CoreText.h" #import "DotReplacementUtils.h" #import "ImageAttachment.h" #import "KeyboardUtils.h" #import "LayoutManagerExtension.h" #import "ParagraphAttributesUtils.h" +#import "ParagraphsUtils.h" #import "RCTFabricComponentsPlugins.h" #import "StringExtension.h" #import "StyleHeaders.h" @@ -54,6 +56,7 @@ @implementation EnrichedTextInputView { NSMutableDictionary *_attachmentViews; NSArray *_contextMenuItems; NSString *_submitBehavior; + NSString *_recentlyEmittedAlignment; } // MARK: - Component utils @@ -92,6 +95,7 @@ - (void)setDefaults { _blockedStyles = [[NSMutableSet alloc] init]; _recentlyActiveLinkRange = NSMakeRange(0, 0); _recentlyActiveMentionRange = NSMakeRange(0, 0); + _recentlyEmittedAlignment = @"left"; _recentInputString = @""; _recentlyEmittedHtml = @"\n

\n"; _emitHtml = NO; @@ -1156,12 +1160,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 getType]), @@ -1182,7 +1194,8 @@ - (void)tryUpdatingActiveStyles { .blockQuote = GET_STYLE_STATE([BlockQuoteStyle getType]), .codeBlock = GET_STYLE_STATE([CodeBlockStyle getType]), .image = GET_STYLE_STATE([ImageStyle getType]), - .checkboxList = GET_STYLE_STATE([CheckboxListStyle getType])}); + .checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]), + .alignment = [currentAlignment UTF8String]}); } } @@ -1339,6 +1352,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 58f4620a3..c46525a38 100644 --- a/ios/extensions/LayoutManagerExtension.mm +++ b/ios/extensions/LayoutManagerExtension.mm @@ -271,6 +271,7 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput NSForegroundColorAttributeName : [typedInput->config orderedListMarkerColor] }; + CGFloat indent = pStyle.firstLineHeadIndent; NSArray *paragraphs = [RangeUtils getSeparateParagraphsRangesIn:typedInput->textView @@ -313,21 +314,24 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput marker:marker markerAttributes:markerAttributes origin:origin - usedRect:usedRect]; + usedRect:usedRect + indent:indent]; } else if ([markerFormat isEqualToString: @"EnrichedUnorderedLis" @"t"]) { [self drawBullet:typedInput origin:origin - usedRect:textUsedRect]; + usedRect:textUsedRect + indent:indent]; } else if ([markerFormat hasPrefix: @"EnrichedCheckbox"]) { [self drawCheckbox:typedInput markerFormat:markerFormat origin:origin - usedRect:textUsedRect]; + usedRect:textUsedRect + indent:indent]; } // only first line of a list gets its // marker drawn @@ -395,7 +399,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:@"EnrichedCheckbox1"]; UIImage *image = isChecked ? typedInput->config.checkboxCheckedImage @@ -404,7 +409,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)]; @@ -412,10 +417,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(); @@ -433,10 +439,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 ad08bb9c0..2caa6bcad 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" @@ -220,19 +222,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 getType])]) { inUnorderedList = YES; - [result appendString:@"\n
    "]; + [result + appendString:[NSString stringWithFormat:@"\n", styleAttr]]; } // handle starting ordered list if (!inOrderedList && [currentActiveStyles containsObject:@([OrderedListStyle getType])]) { inOrderedList = YES; - [result appendString:@"\n
      "]; + [result + appendString:[NSString stringWithFormat:@"\n", styleAttr]]; } // handle starting blockquotes if (!inBlockQuote && @@ -251,7 +258,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { [currentActiveStyles containsObject:@([CheckboxListStyle getType])]) { inCheckboxList = YES; - [result appendString:@"\n
        "]; + [result appendString:[NSString stringWithFormat: + @"\n
          ", + styleAttr]]; } // don't add the

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

          "]; + [result appendString:[NSString stringWithFormat:@"", styleAttr]]; } } @@ -476,6 +485,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 getType])]) { return @"b"; } else if ([style isEqualToNumber:@([ItalicStyle getType])]) { @@ -555,17 +567,17 @@ - (NSString *)tagContentForStyle:(NSNumber *)style return @"mention"; } } else if ([style isEqualToNumber:@([H1Style getType])]) { - return @"h1"; + return [NSString stringWithFormat:@"h1%@", styleAttr]; } else if ([style isEqualToNumber:@([H2Style getType])]) { - return @"h2"; + return [NSString stringWithFormat:@"h2%@", styleAttr]; } else if ([style isEqualToNumber:@([H3Style getType])]) { - return @"h3"; + return [NSString stringWithFormat:@"h3%@", styleAttr]; } else if ([style isEqualToNumber:@([H4Style getType])]) { - return @"h4"; + return [NSString stringWithFormat:@"h4%@", styleAttr]; } else if ([style isEqualToNumber:@([H5Style getType])]) { - return @"h5"; + return [NSString stringWithFormat:@"h5%@", styleAttr]; } else if ([style isEqualToNumber:@([H6Style getType])]) { - return @"h6"; + return [NSString stringWithFormat:@"h6%@", styleAttr]; } else if ([style isEqualToNumber:@([UnorderedListStyle getType])] || [style isEqualToNumber:@([OrderedListStyle getType])]) { return @"li"; @@ -585,13 +597,17 @@ - (NSString *)tagContentForStyle:(NSNumber *)style } } else if ([style isEqualToNumber:@([BlockQuoteStyle getType])] || [style isEqualToNumber:@([CodeBlockStyle getType])]) { - // blockquotes and codeblock use

          tags the same way lists use

        • - return @"p"; + return [NSString stringWithFormat:@"p%@", styleAttr]; } return @""; } - (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 = @""; _input->textView.typingAttributes = _input->defaultTypingAttributes; @@ -616,6 +632,7 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { // set new text _input->textView.text = html; } + [self applyAlignments:alignments offset:0]; } - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { @@ -623,6 +640,7 @@ - (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 @@ -644,6 +662,7 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { input:_input withSelection:YES]; } + [self applyAlignments:alignments offset:range.location]; } - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { @@ -651,6 +670,7 @@ - (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 @@ -672,6 +692,7 @@ - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { input:_input withSelection:YES]; } + [self applyAlignments:alignments offset:location]; } - (void)applyProcessedStyles:(NSArray *)processedStyles @@ -1152,6 +1173,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; @@ -1192,8 +1215,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 @@ -1202,6 +1224,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]; @@ -1254,6 +1283,9 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { mutableCopy]; } + [self checkForAlignments:ongoingTags[currentTagName] + plainText:plainText + foundAlignments:foundAlignments]; [self finalizeTagEntry:currentTagName ongoingTags:ongoingTags initiallyProcessedTags:initiallyProcessedTags @@ -1484,7 +1516,7 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { [processedStyles addObject:styleArr]; } - return @[ plainText, processedStyles ]; + return @[ plainText, processedStyles, foundAlignments ]; } - (BOOL)isUlCheckboxList:(NSString *)params { @@ -1507,4 +1539,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 0f4a4a199..ca76f7d15 100644 --- a/ios/utils/ParagraphAttributesUtils.mm +++ b/ios/utils/ParagraphAttributesUtils.mm @@ -43,6 +43,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 styles that need ZWS (lists, quotes, etc.) we do the following: // - manually do the removing // - reset typing attributes so that the previous line styles don't get @@ -58,8 +64,8 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = - typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput + preserveAlignment:savedAlignment]; if (style == cbLStyle) { [cbLStyle addWithChecked:isCurrentlyChecked @@ -67,9 +73,15 @@ + (BOOL)handleBackspaceInRange:(NSRange)range withTyping:YES withDirtyRange:YES]; } else { - [style add:NSMakeRange(range.location, 0) - withTyping:YES - withDirtyRange:YES]; + [TextInsertionUtils replaceText:text + at:range + additionalAttributes:nullptr + input:typedInput + withSelection:YES]; + [self resetTypingAttributes:typedInput + preserveAlignment:savedAlignment]; + [style addAttributes:NSMakeRange(range.location, 0) + withTypingAttr:YES]; } return YES; } @@ -82,7 +94,7 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr input:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput preserveAlignment:savedAlignment]; return YES; } @@ -231,19 +243,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 20927ba0c..55a03cf0d 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]; @@ -54,6 +60,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/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index a58756bc9..cc57a3a92 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -270,6 +270,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 83e557d5f..f7743fc6a 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -123,6 +123,7 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: 'left' | 'center' | 'right' | 'justify' | 'default'; } export interface OnLinkDetected { @@ -273,6 +274,7 @@ export interface OnContextMenuItemPressEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: 'left' | 'center' | 'right' | 'justify' | 'default'; }; } @@ -469,6 +471,10 @@ interface NativeCommands { viewRef: React.ElementRef, requestId: Int32 ) => void; + setTextAlignment: ( + viewRef: React.ElementRef, + alignment: string + ) => void; } export const Commands: NativeCommands = codegenNativeCommands({ @@ -502,6 +508,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'startMention', 'addMention', 'requestHTML', + 'setTextAlignment', ], }); diff --git a/src/types.ts b/src/types.ts index ada3ceb69..4804c73ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -340,6 +340,7 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: 'left' | 'center' | 'right' | 'justify' | 'default'; } export interface OnLinkDetected { @@ -417,6 +418,9 @@ export interface EnrichedTextInputInstance extends NativeMethods { text: string, attributes?: Record ) => void; + setTextAlignment: ( + alignment: 'left' | 'center' | 'right' | 'justify' | 'default' + ) => void; } export interface ContextMenuItem { diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index ed3476e9c..6b9548654 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -145,6 +145,7 @@ export const EnrichedTextInput = ({ measureInWindow: () => {}, measureLayout: () => {}, setNativeProps: () => {}, + setTextAlignment: () => {}, }) );