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..42cb7f4f5 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 @@ -225,6 +225,16 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int out.append("<"); out.append(tagType); + AlignmentSpan.Standard[] alignments = text.getSpans(i, next, AlignmentSpan.Standard.class); + if (alignments.length > 0) { + Layout.Alignment align = alignments[alignments.length - 1].getAlignment(); + if (align == Layout.Alignment.ALIGN_CENTER) { + out.append(" style=\"text-align: center\""); + } else if (align == Layout.Alignment.ALIGN_OPPOSITE) { + out.append(" style=\"text-align: right\""); + } + } + if (isCheckboxListItem) { EnrichedCheckboxListSpan[] checkboxSpans = text.getSpans(i, next, EnrichedCheckboxListSpan.class); @@ -462,16 +472,16 @@ private void handleStartTag(String tag, Attributes attributes) { // so we can safely emit the linebreaks when we handle the close tag. } else if (tag.equalsIgnoreCase("p")) { isEmptyTag = true; - startBlockElement(mSpannableStringBuilder); + startBlockElement(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("ul")) { isInOrderedList = false; String dataType = attributes.getValue("", "data-type"); isInCheckboxList = "checkbox".equals(dataType); - startBlockElement(mSpannableStringBuilder); + startBlockElement(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("ol")) { isInOrderedList = true; currentOrderedListItemIndex = 0; - startBlockElement(mSpannableStringBuilder); + startBlockElement(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("li")) { isEmptyTag = true; startLi(mSpannableStringBuilder, attributes); @@ -481,10 +491,10 @@ private void handleStartTag(String tag, Attributes attributes) { start(mSpannableStringBuilder, new Italic()); } else if (tag.equalsIgnoreCase("blockquote")) { isEmptyTag = true; - startBlockquote(mSpannableStringBuilder); + startBlockquote(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("codeblock")) { isEmptyTag = true; - startCodeBlock(mSpannableStringBuilder); + startCodeBlock(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("a")) { startA(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("u")) { @@ -494,17 +504,17 @@ private void handleStartTag(String tag, Attributes attributes) { } else if (tag.equalsIgnoreCase("strike")) { start(mSpannableStringBuilder, new Strikethrough()); } else if (tag.equalsIgnoreCase("h1")) { - startHeading(mSpannableStringBuilder, 1); + startHeading(mSpannableStringBuilder, 1, attributes); } else if (tag.equalsIgnoreCase("h2")) { - startHeading(mSpannableStringBuilder, 2); + startHeading(mSpannableStringBuilder, 2, attributes); } else if (tag.equalsIgnoreCase("h3")) { - startHeading(mSpannableStringBuilder, 3); + startHeading(mSpannableStringBuilder, 3, attributes); } else if (tag.equalsIgnoreCase("h4")) { - startHeading(mSpannableStringBuilder, 4); + startHeading(mSpannableStringBuilder, 4, attributes); } else if (tag.equalsIgnoreCase("h5")) { - startHeading(mSpannableStringBuilder, 5); + startHeading(mSpannableStringBuilder, 5, attributes); } else if (tag.equalsIgnoreCase("h6")) { - startHeading(mSpannableStringBuilder, 6); + startHeading(mSpannableStringBuilder, 6, attributes); } else if (tag.equalsIgnoreCase("img")) { startImg(mSpannableStringBuilder, attributes, mSpanFactory); } else if (tag.equalsIgnoreCase("code")) { @@ -573,9 +583,24 @@ private static void appendNewlines(Editable text, int minNewline) { } } - private static void startBlockElement(Editable text) { + private static void startBlockElement(Editable text, Attributes attributes) { appendNewlines(text, 1); start(text, new Newline(1)); + + if (attributes != null) { + String style = attributes.getValue("", "style"); + if (style != null) { + if (style.contains("text-align: center")) { + start(text, new Alignment(Layout.Alignment.ALIGN_CENTER)); + } else if (style.contains("text-align: right")) { + start(text, new Alignment(Layout.Alignment.ALIGN_OPPOSITE)); + } + } + } + } + + private static void startBlockElement(Editable text) { + startBlockElement(text, null); } private static void endBlockElement(Editable text) { @@ -595,7 +620,7 @@ private static void handleBr(Editable text) { } private void startLi(Editable text, Attributes attributes) { - startBlockElement(text); + startBlockElement(text, attributes); if (isInOrderedList) { currentOrderedListItemIndex++; @@ -625,8 +650,8 @@ private static void endLi(Editable text, T style, EnrichedSpanFactory spa endBlockElement(text); } - private void startBlockquote(Editable text) { - startBlockElement(text); + private void startBlockquote(Editable text, Attributes attributes) { + startBlockElement(text, attributes); start(text, new Blockquote()); } @@ -637,8 +662,8 @@ private static void endBlockquote( setParagraphSpanFromMark(text, last, spanFactory.createBlockQuoteSpan(style)); } - private void startCodeBlock(Editable text) { - startBlockElement(text); + private void startCodeBlock(Editable text, Attributes attributes) { + startBlockElement(text, attributes); start(text, new CodeBlock()); } @@ -648,8 +673,8 @@ private static void endCodeBlock(Editable text, T style, EnrichedSpanFactory setParagraphSpanFromMark(text, last, spanFactory.createCodeBlockSpan(style)); } - private void startHeading(Editable text, int level) { - startBlockElement(text); + private void startHeading(Editable text, int level, Attributes attributes) { + startBlockElement(text, attributes); switch (level) { case 1: 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..2e1607957 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -10,7 +10,9 @@ import android.graphics.Rect import android.graphics.text.LineBreaker import android.os.Build import android.text.InputType +import android.text.Layout import android.text.Spannable +import android.text.style.AlignmentSpan import android.util.AttributeSet import android.util.Log import android.util.Patterns @@ -475,6 +477,55 @@ class EnrichedTextInputView : AppCompatEditText { ) } + fun setParagraphAlignment(alignment: String) { + val align = + when (alignment) { + "center" -> Layout.Alignment.ALIGN_CENTER + "right" -> Layout.Alignment.ALIGN_OPPOSITE + else -> Layout.Alignment.ALIGN_NORMAL + } + + val targetRange = paragraphStyles?.getStyleRange() ?: return + runAsATransaction { + var start = targetRange.first + var end = targetRange.second + if (start == end) { + text?.insert(start, EnrichedConstants.ZWS_STRING) + end += 1 + } + removeParagraphAlignment(start, end) + val span = AlignmentSpan.Standard(align) + (text as? Spannable)?.setSpan(span, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + layoutManager.invalidateLayout() + selection?.validateStyles() + requestFocusProgrammatically() + } + + fun getCurrentParagraphAlignment(): String { + val targetRange = paragraphStyles?.getStyleRange() ?: return "left" + val spannable = text as? Spannable ?: return "left" + val spans = spannable.getSpans(targetRange.first, targetRange.second, AlignmentSpan.Standard::class.java) + if (spans.isEmpty()) return "left" + val span = spans.last() + return when (span.alignment) { + Layout.Alignment.ALIGN_CENTER -> "center" + Layout.Alignment.ALIGN_OPPOSITE -> "right" + else -> "left" + } + } + + private fun removeParagraphAlignment( + start: Int, + end: Int, + ) { + val spannable = text as? Spannable ?: return + val spans = spannable.getSpans(start, end, AlignmentSpan.Standard::class.java) + for (span in spans) { + spannable.removeSpan(span) + } + } + fun setFontFamily(family: String?) { if (family != fontFamily) { fontFamily = family 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..a9252665f 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -422,6 +422,13 @@ class EnrichedTextInputViewManager : view?.requestHTML(requestId) } + override fun setParagraphAlignment( + view: EnrichedTextInputView?, + alignment: String, + ) { + view?.setParagraphAlignment(alignment) + } + override fun measure( context: Context, localData: ReadableMap?, 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..132cccb88 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt @@ -104,6 +104,8 @@ class EnrichedSelection( for ((style, config) in EnrichedSpans.parametrizedStyles) { state.setStart(style, getParametrizedStyleStart(config.clazz)) } + + state.setAlignment(view.getCurrentParagraphAlignment()) } fun getInlineSelection(): Pair { 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..bdbad463e 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt @@ -52,6 +52,13 @@ class EnrichedSpanState( private set var mentionStart: Int? = null private set + var alignment: String? = null + private set + + fun setAlignment(alignment: String) { + this.alignment = alignment + emitStateChangeEvent() + } fun setBoldStart(start: Int?) { this.boldStart = start @@ -246,6 +253,7 @@ class EnrichedSpanState( payload.putMap("image", getStyleState(activeStyles, EnrichedSpans.IMAGE)) payload.putMap("mention", getStyleState(activeStyles, EnrichedSpans.MENTION)) payload.putMap("checkboxList", getStyleState(activeStyles, EnrichedSpans.CHECKBOX_LIST)) + payload.putString("alignment", this.alignment ?: view.getCurrentParagraphAlignment()) return payload } 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..387eafb67 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 @@ -85,8 +85,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 only if we change one of ours spans or alignment + if (what != null && what !is EnrichedInputSpan && what !is android.text.style.AlignmentSpan.Standard) return val html = EnrichedParser.toHtml(s) if (html == previousHtml) return diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index af2328d86..cb30b8bc5 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -2064,81 +2064,81 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c12d2108050e27952983d565a232f6f7b1ad5e69 - hermes-engine: 57506c1404e8b80c5386de18743e0d02c8d4f681 + hermes-engine: 500ff256d14bff88530c537966595a790099cd80 RCTDeprecation: 3280799c14232a56e5a44f92981a8ee33bc69fd9 RCTRequired: 9854a51b0f65ccf43ea0b744df4d70fce339db32 RCTSwiftUI: 96986e49a4fdc2c2103929dee2641e1b57edf33d - RCTSwiftUIWrapper: e3eed9f50cad9f171e4487e2ff18a9caa4d46bfb + RCTSwiftUIWrapper: 55e482219b78c2c652123fea845a6b1716fa97e7 RCTTypeSafety: e9ba155357c236764934054ee2d393fd76e7b36b React: 7ef36630d07638043a134a7dd2ec17e0be10fc3c React-callinvoker: af4e8fe1d60ab63dd8d74c2a68988064c2848954 - React-Core: c0fb1df65eb0ed7a8633841831f05f93c3eb3aff - React-Core-prebuilt: cd92350bf2041dde22a9bc0b8984d9c70d179ca1 - React-CoreModules: 7dfe7962360355f1547c85ab52e1fc4b57f17127 - React-cxxreact: 9e9c7f1710bc58abebf924813b5e825b99adb8e5 + React-Core: c609976c034ba9556bef9850a571a71bd458d73f + React-Core-prebuilt: 54f9f16cbdfa6704be637974582612ced01b2993 + React-CoreModules: 0ea85f3b3f4b8cbfb3afacd2ed85458fb878517a + React-cxxreact: 6752bab77c0599d6136e2b8b9b64b4a7d316d401 React-debug: 38389b86e3570558ec73dd4cbc0cd2f2eec47a51 - React-defaultsnativemodule: a326ccbb71369762888a6be09a23fa5bce2bdb6a - React-domnativemodule: 8394c7b535d1b484b1eab677e00b086507cd906a - React-Fabric: 682dafd75455062590cd1f63c79199cf72ff27d9 - React-FabricComponents: 11b13a53213cd1aaca3bf7f4c61c669617b26b5f - React-FabricImage: 706c27e82f77b77db96ab3a19009ddb5e777967f - React-featureflags: c2898fb2f93ab92cfd9f294b4531d2884e7cfc7e - React-featureflagsnativemodule: 1edf93adfa12ba4f15d07079c1675b55ff579477 - React-graphics: 57d042385bfef5104aafeab189f43b8d6145013b - React-hermes: 96d2d439f0477a93fe8e801664088eccc07a16ff - React-idlecallbacksnativemodule: ab4dc6c3657f434f82c568ca83c963791e783f6a - React-ImageManager: f39057f375cf3f98255fb751df3865a91f2755c1 - React-intersectionobservernativemodule: 54ce679b183149fd9566a79211f2f54dc0a6fd1f - React-jserrorhandler: 2e92acff04ac815c6066c7cc08ea302610045db1 - React-jsi: dc97891e1ee7fa17cad01cd150c50f21e04bd51b - React-jsiexecutor: e1543ba5a8be761331c8158d91211079cc5b73a2 - React-jsinspector: 7a1d86673986db6666cacc8b95e92125397ab6ea - React-jsinspectorcdp: 38a0c116fd4965abf29261721db9b903923cb723 - React-jsinspectornetwork: cfeace6b40f13ba82980ba7cb730847a35675c7f - React-jsinspectortracing: 5507411117e51751dba0543cdee7916eb0388693 - React-jsitooling: e3a2df9043ab7b9ad11bbbfe4b33eb6762514f05 - React-jsitracing: ad179fab1c1e08a57fcdb840b7021b453f7a2b6d - React-logger: e40cc24a61d3a54c09bf4e83d5556b3b9d4c90aa - React-Mapbuffer: 53f28c81b84767a0b2fb4c0109dd7e4571226f76 - React-microtasksnativemodule: ddaf25a8d69f694bc880fb6055e34d79f1d50138 - react-native-image-picker: 7793e84ad7b802351cf687c18bb42ad30150b29b - React-NativeModulesApple: 14a8919451154ede904f2bca84b27703a09028ba - React-networking: 46c0037f9202c1919493b78662a47cbe13022fdd + React-defaultsnativemodule: c1c25636322de460083d9291bc813067aa706552 + React-domnativemodule: d1734e540ea9344ec1a024de5704d39063935049 + React-Fabric: 48a292ed21257b0d9639e3c4cc49047a2c8a7f8f + React-FabricComponents: 932d81e7e2de71c25a63c1832f76f123e1a091f3 + React-FabricImage: adbb7b606e96add2785646a1c81e285367f0d249 + React-featureflags: 2a6f0a8f885559e1192e8bb0c173de638529df20 + React-featureflagsnativemodule: 255521af601b622048ec50b5dbf104cc886762a8 + React-graphics: ddca902e78ca64a824c31d206b083a3f207d6d06 + React-hermes: b5df3aebd45da232c6e8c9d925260e9d64122d03 + React-idlecallbacksnativemodule: fb05344181eeb52c5fd54597599b6e71b05dcf21 + React-ImageManager: 4861de430733ee8cf9fd06acb9fdf65d8d551f9d + React-intersectionobservernativemodule: 6699faee489b3439a4961270e880d814690f4eea + React-jserrorhandler: a3a9796a152ddd2712403d7cd7903624003db4b6 + React-jsi: 2c0a2219dacbdf776c5c911fae6f8923813d1ff2 + React-jsiexecutor: 0c4082df04719e747ae6d728e4e238ef1de16457 + React-jsinspector: f4d6e379303d120888cd1e15e3e7e1b2b4d41b37 + React-jsinspectorcdp: 0147c000c3a9e05082d974fdb05f8fc0c470787d + React-jsinspectornetwork: d5e1b2d72d1a205aee8141906735bd85c4cb9a7c + React-jsinspectortracing: 8d52e3fcb00cf6c4fcdeac0ec7b10bfe819693e1 + React-jsitooling: 455d72275baff87bd39a8a1b315e0bd8b13fa8e9 + React-jsitracing: 5a15b0ecc476e47533236dbbf2b6e670d6d8aa41 + React-logger: 9e51e01455f15cb3ef87a09a1ec773cdb22d56c1 + React-Mapbuffer: 92b99e450e8ff598b27d6e4db3a75e04fd45e9a9 + React-microtasksnativemodule: 2fe0f2bd2840dedbd66c0ac249c64f977f39cc18 + react-native-image-picker: 48d850454b4a389753053e1d7378b624d3b47d77 + React-NativeModulesApple: 44a9474594566cd03659f92e38f42599c6b9dee4 + React-networking: db73d91466cb134fcbdaaa579fb2de14e2c2ea01 React-oscompat: b924b8609d06899f00ab1aa813b0cde9c5e12771 - React-perflogger: c3bb13800f795287e73a8c1991a2b8e5008ea3d0 - React-performancecdpmetrics: 851d2b18ba3d3d8cfb309bf468e5e93e46601122 - React-performancetimeline: 0a960aee139987151d2976813c47bef17dea3d3a + React-perflogger: 8afbf1c6c0e6d8f869cb2917492db19dc212312d + React-performancecdpmetrics: 9034e89102afda66d6c6fcb43233c24f3927fa78 + React-performancetimeline: 7860aafe1782694fa6b5ce7bae0dfd199fe049f7 React-RCTActionSheet: 21fbcd85f552d5d6575453d2e8c149535d9c6f46 - React-RCTAnimation: 2c8cb9508864bb15e9f8fe86242d8918f05278e9 - React-RCTAppDelegate: 1d52e34d25f5f1bed5c07e0717c40dc572a80010 - React-RCTBlob: bc487ebb909c23920af75c842b1405edba61b8ea - React-RCTFabric: 7de87d2635b95171a06d9fffd907c4ac17823ef2 - React-RCTFBReactNativeSpec: b3936c48bf5262dc57ba28f8c8208cd1b570964c - React-RCTImage: a591fc9f08dc6c7b63b9fb34f51a7c1f32bd9595 - React-RCTLinking: cb9553b27de77a63beb4e3ce95f82aa8f3bed602 - React-RCTNetwork: 576ba853aef49628238b4840e969217b826af156 - React-RCTRuntime: e0aa5ea63ba4e06c9028da5ae8b05cf72bc8a1ea - React-RCTSettings: 8caa15edae452a5c4cd064569d5357a2bee8de15 - React-RCTText: af9a1c8d7c135c4d3ffa2de253ca95544234a521 - React-RCTVibration: c1dd36479ca1c1a59d16db81e5a994e9be06a68b + React-RCTAnimation: e8840d9f68bbbcf766909d738b021d2c0df1be36 + React-RCTAppDelegate: 0a491adac54f255d549656cc016c61102aaddfb7 + React-RCTBlob: f3eceaf519cf0f7f159bb653b3431b26c956400f + React-RCTFabric: 6278c2bc45c4b5685f7d7027d86343048b3d906b + React-RCTFBReactNativeSpec: ba5c77a9658d3acb7cbc5653162661df1d63ed25 + React-RCTImage: 928e5125c8e5407f3c6c62d51593eb8000fb2a36 + React-RCTLinking: 80c236e6e837d297750aac8da269fab24d4e0fc1 + React-RCTNetwork: 9554720800c31ec6608ed8047a314252e40008ac + React-RCTRuntime: d37d53534c207677f86df9b9cb30b7dde8857327 + React-RCTSettings: 52a066ceedda0253d75755909ee14a11972b16dc + React-RCTText: dd2964c3f003549ef3ce9ac5b7966d1c79dc5875 + React-RCTVibration: dc9e7490a0e270b1ec905c13714434c809a276fa React-rendererconsistency: 32e7b98c05a3f237ecb524add21190036962e868 - React-renderercss: d65e9232e5033cd9c07b13fa429ce925b8143bd7 - React-rendererdebug: 25c6151116b7ea1f78af72afc64f2066ad29a61d - React-RuntimeApple: e036929884cc0d8088fe8a5a2d210e068d35e608 - React-RuntimeCore: 0c8a252051fe6b627f5147ac5b6a5298951472a8 - React-runtimeexecutor: 0765dddf1842e23e87ad13b2cb1bb72bb9005aeb - React-RuntimeHermes: 44cd4fdc4afa44fa782ddce8600e3cc90215fbc5 - React-runtimescheduler: 1966ff307933cdbafd480cb3aa1fdc90d9a6d539 - React-timing: 94c4a44dd2d10e4fc51fd42654fd5f67d68247ad - React-utils: 172d467a9c037d5ed51ee6eeaa6ad30ca1ebe1b1 - React-webperformancenativemodule: 9e3c5032dd30bf6418b741ab54ad26187b1c94c3 - ReactAppDependencyProvider: 625d2f6d9d5ef01acc9dfe2b5385504bbffd2ad0 - ReactCodegen: 27937747ddc743fcb66a8dc19e8edf60188d94cc - ReactCommon: cc0e38600f82487c5fe5d29150abb6fa9d981986 - ReactNativeDependencies: cebf665879bab2908201494cc5a9760dbdf0a637 - ReactNativeEnriched: 4269ef1190b05845dffbed287c472df4e7976077 + React-renderercss: b5f27fdea2162033c44af42bd9da7eefb08603a6 + React-rendererdebug: 09e9a23444c9319c965d7f981f8f2d57d2f88428 + React-RuntimeApple: 7c2c6aff02da8f1d89c211baa0a98bb76b01dfed + React-RuntimeCore: 4b3688f2ddcaf644f8e645ec45b4d77ec9cf58f9 + React-runtimeexecutor: 8540253f799008af0485a9ec417b001e73a9dede + React-RuntimeHermes: abc8b8b62dec3d3f5d685d586dd4b2381fc36ea8 + React-runtimescheduler: a64d5a112f8f8bc58af6f3e3382452f6f91002c6 + React-timing: 1f40175beb4b55fa3f6de9f947cd7ed9275deb25 + React-utils: e157d1837edbb842b9c0201a6a144a4d3d395246 + React-webperformancenativemodule: 295dde5803df595cd9a266f44e4371bbf12a600e + ReactAppDependencyProvider: 6b7e8d8d974ed13fb66698d82c30c5e70c1f7d3a + ReactCodegen: c5e5343f6691b0cd76913b9be5e89e5a83ff3315 + ReactCommon: 92b53b0bd7f7d86154dc9f512c1ea5dee717cc72 + ReactNativeDependencies: 45dcc4fab8d93f8b7eddc8efe34c08f304c6104d + ReactNativeEnriched: 7d7f28e114d9b260d1f1986905a2ec130b70df6c Yoga: 772166513f9cd2d61a6251d0dacbbfaa5b537479 PODFILE CHECKSUM: 88c10840d02e9884b2dc3f457d5120f83ac3803b -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index f4801c0ac..5edb2afb8 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?.setParagraphAlignment('left'); + break; + case 'align-center': + editorRef.current?.setParagraphAlignment('center'); + break; + case 'align-right': + editorRef.current?.setParagraphAlignment('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/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 410b456cf..cf25a8886 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 paragraph alignment of the current selection (one of `left`, `center`, or `right`). | 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 @@ -531,6 +533,16 @@ getHTML: () => Promise; Returns a Promise that resolves with the current HTML content of the input. This is useful when you need to get the HTML on-demand (e.g., when saving) without the performance overhead of continuous HTML parsing via `onChangeHtml`. +### `.setParagraphAlignment()` + +```ts +setParagraphAlignment: (alignment: 'left' | 'center' | 'right') => void; +``` + +Sets the alignment of the paragraph(s) in the current selection. + +- `alignment` - the alignment to apply (`left`, `center`, or `right`). + ### `.setImage()` ```ts diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 5e81c9f26..da361ff4a 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -44,6 +44,7 @@ @implementation EnrichedTextInputView { MentionParams *_recentlyActiveMentionParams; NSRange _recentlyActiveMentionRange; NSString *_recentlyEmittedHtml; + NSString *_recentlyEmittedAlignment; BOOL _emitHtml; UILabel *_placeholderLabel; UIColor *_placeholderColor; @@ -92,6 +93,7 @@ - (void)setDefaults { recentlyChangedRange = NSMakeRange(0, 0); _recentInputString = @""; _recentlyEmittedHtml = @"\n

\n"; + _recentlyEmittedAlignment = @"left"; _emitHtml = NO; blockEmitting = NO; _emitFocusBlur = YES; @@ -1121,6 +1123,12 @@ - (void)tryUpdatingActiveStyles { } } + NSString *currentAlignment = [self currentParagraphAlignmentString]; + if (![currentAlignment isEqualToString:_recentlyEmittedAlignment]) { + updateNeeded = YES; + _recentlyEmittedAlignment = currentAlignment; + } + if (updateNeeded) { auto emitter = [self getEventEmitter]; if (emitter != nullptr) { @@ -1147,7 +1155,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 = [_recentlyEmittedAlignment toCppString]}); } } @@ -1208,6 +1217,80 @@ - (void)removeStyleBlock:(StyleType)blocking from:(StyleType)blocked { } } +// MARK: - Paragraph alignment + +- (NSString *)currentParagraphAlignmentString { + NSRange currentRange = textView.selectedRange; + if (currentRange.location >= textView.textStorage.string.length) { + currentRange.location = textView.textStorage.string.length > 0 + ? textView.textStorage.string.length - 1 + : 0; + } + + NSParagraphStyle *paragraphStyle; + if (textView.textStorage.length == 0) { + // When text is empty, check the typing attributes for alignment + paragraphStyle = textView.typingAttributes[NSParagraphStyleAttributeName]; + } else { + paragraphStyle = + [textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:currentRange.location + effectiveRange:NULL]; + } + + NSTextAlignment align = + paragraphStyle ? paragraphStyle.alignment : NSTextAlignmentLeft; + + switch (align) { + case NSTextAlignmentCenter: + return @"center"; + case NSTextAlignmentRight: + return @"right"; + default: + return @"left"; + } +} + +- (void)setParagraphAlignment:(NSString *)alignment { + [self focus]; + NSTextAlignment align = NSTextAlignmentNatural; + if ([alignment isEqualToString:@"center"]) { + align = NSTextAlignmentCenter; + } else if ([alignment isEqualToString:@"right"]) { + align = NSTextAlignmentRight; + } + + NSRange paragraphRange = [textView.textStorage.string + paragraphRangeForRange:textView.selectedRange]; + + [textView.textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:paragraphRange + options:0 + usingBlock:^(id _Nullable value, NSRange range, + BOOL *_Nonnull stop) { + NSMutableParagraphStyle *pStyle = + value ? [(NSParagraphStyle *)value mutableCopy] + : [[NSMutableParagraphStyle alloc] init]; + pStyle.alignment = align; + [textView.textStorage addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:range]; + }]; + + NSMutableParagraphStyle *typingPStyle = + textView.typingAttributes[NSParagraphStyleAttributeName] + ? [textView.typingAttributes[NSParagraphStyleAttributeName] + mutableCopy] + : [[NSMutableParagraphStyle alloc] init]; + typingPStyle.alignment = align; + NSMutableDictionary *newTypingAttrs = [textView.typingAttributes mutableCopy]; + newTypingAttrs[NSParagraphStyleAttributeName] = typingPStyle; + textView.typingAttributes = newTypingAttrs; + + [self anyTextMayHaveBeenModified]; +} + // MARK: - Native commands and events - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { @@ -1246,6 +1329,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { } else if ([commandName isEqualToString:@"startMention"]) { NSString *indicator = (NSString *)args[0]; [self startMentionWithIndicator:indicator]; + } else if ([commandName isEqualToString:@"setParagraphAlignment"]) { + NSString *alignment = (NSString *)args[0]; + [self setParagraphAlignment:alignment]; } else if ([commandName isEqualToString:@"toggleH1"]) { [self toggleParagraphStyle:[H1Style getStyleType]]; } else if ([commandName isEqualToString:@"toggleH2"]) { @@ -1753,10 +1839,13 @@ - (void)anyTextMayHaveBeenModified { } // placholder management - if (!_placeholderLabel.hidden && textView.textStorage.string.length > 0) { + BOOL isFocused = [textView isFirstResponder]; + BOOL hasText = textView.textStorage.string.length > 0; + BOOL shouldShowPlaceholder = !hasText && !isFocused; + + if (!_placeholderLabel.hidden && !shouldShowPlaceholder) { [self setPlaceholderLabelShown:NO]; - } else if (textView.textStorage.string.length == 0 && - _placeholderLabel.hidden) { + } else if (_placeholderLabel.hidden && shouldShowPlaceholder) { [self setPlaceholderLabelShown:YES]; } @@ -1850,6 +1939,7 @@ - (void)didMoveToWindow { // MARK: - UITextView delegate methods - (void)textViewDidBeginEditing:(UITextView *)textView { + [self anyTextMayHaveBeenModified]; auto emitter = [self getEventEmitter]; if (emitter != nullptr) { // send onFocus event if allowed @@ -1872,6 +1962,7 @@ - (void)textViewDidBeginEditing:(UITextView *)textView { } - (void)textViewDidEndEditing:(UITextView *)textView { + [self anyTextMayHaveBeenModified]; auto emitter = [self getEventEmitter]; if (emitter != nullptr && _emitFocusBlur) { // send onBlur event diff --git a/ios/inputParser/InputParser.mm b/ios/inputParser/InputParser.mm index 1348728de..81476b730 100644 --- a/ios/inputParser/InputParser.mm +++ b/ios/inputParser/InputParser.mm @@ -274,7 +274,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { containsObject:@([CheckboxListStyle getStyleType])]) { [result appendString:@"\n"]; } else { - [result appendString:@"\n

"]; + [result appendString:@"\n"]; } } @@ -368,7 +370,11 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { appendString:[NSString stringWithFormat:@"<%@/>", tagContent]]; [currentActiveStyles removeObject:@([ImageStyle getStyleType])]; } else { - [result appendString:[NSString stringWithFormat:@"<%@>", tagContent]]; + [result appendString:[NSString stringWithFormat:@"<%@", tagContent]]; + if ([self isBlockStyle:style]) { + [self appendAlignment:currentRange.location toResult:result]; + } + [result appendString:@">"]; } } @@ -601,6 +607,39 @@ - (NSString *)tagContentForStyle:(NSNumber *)style return @""; } +- (BOOL)isBlockStyle:(NSNumber *)style { + if ([style isEqualToNumber:@([H1Style getStyleType])] || + [style isEqualToNumber:@([H2Style getStyleType])] || + [style isEqualToNumber:@([H3Style getStyleType])] || + [style isEqualToNumber:@([H4Style getStyleType])] || + [style isEqualToNumber:@([H5Style getStyleType])] || + [style isEqualToNumber:@([H6Style getStyleType])] || + [style isEqualToNumber:@([UnorderedListStyle getStyleType])] || + [style isEqualToNumber:@([OrderedListStyle getStyleType])] || + [style isEqualToNumber:@([CheckboxListStyle getStyleType])] || + [style isEqualToNumber:@([BlockQuoteStyle getStyleType])] || + [style isEqualToNumber:@([CodeBlockStyle getStyleType])]) { + return YES; + } + return NO; +} + +- (void)appendAlignment:(NSInteger)location toResult:(NSMutableString *)result { + if (location >= _input->textView.textStorage.length) + return; + NSParagraphStyle *paragraphStyle = + [_input->textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:location + effectiveRange:NULL]; + NSTextAlignment align = + paragraphStyle ? paragraphStyle.alignment : NSTextAlignmentLeft; + if (align == NSTextAlignmentCenter) { + [result appendString:@" style=\"text-align: center\""]; + } else if (align == NSTextAlignmentRight) { + [result appendString:@" style=\"text-align: right\""]; + } +} + - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { NSArray *processingResult = [self getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; @@ -670,8 +709,26 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles // of course any changes here need to take blocks and conflicts into // consideration - if ([_input handleStyleBlocksAndConflicts:[[baseStyle class] getStyleType] - range:styleRange]) { + if ([styleType isEqualToNumber:@(-1)]) { + NSTextAlignment align = + (NSTextAlignment)[(NSNumber *)stylePair.styleValue integerValue]; + NSMutableParagraphStyle *pStyle = + [_input->textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:styleRange.location + effectiveRange:NULL]; + if (!pStyle) + pStyle = [_input->defaultTypingAttributes[NSParagraphStyleAttributeName] + mutableCopy]; + else + pStyle = [pStyle mutableCopy]; + pStyle.alignment = align; + [_input->textView.textStorage addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:styleRange]; + } else if (baseStyle && + [_input handleStyleBlocksAndConflicts:[[baseStyle class] + getStyleType] + range:styleRange]) { if ([styleType isEqualToNumber:@([LinkStyle getStyleType])]) { NSString *text = [_input->textView.textStorage.string substringWithRange:styleRange]; @@ -1132,8 +1189,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 @@ -1252,6 +1308,33 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { NSMutableArray *styleArr = [[NSMutableArray alloc] init]; StylePair *stylePair = [[StylePair alloc] init]; + + NSRegularExpression *alignRegex = [NSRegularExpression + regularExpressionWithPattern:@"text-align:\\s*(center|right)" + options:0 + error:nil]; + NSTextCheckingResult *alignMatch = + [alignRegex firstMatchInString:params + options:0 + range:NSMakeRange(0, params.length)]; + if (alignMatch) { + NSString *alignStr = + [params substringWithRange:[alignMatch rangeAtIndex:1]]; + NSTextAlignment align = NSTextAlignmentLeft; + if ([alignStr isEqualToString:@"center"]) + align = NSTextAlignmentCenter; + else if ([alignStr isEqualToString:@"right"]) + align = NSTextAlignmentRight; + + NSMutableArray *alignArr = [[NSMutableArray alloc] init]; + StylePair *alignPair = [[StylePair alloc] init]; + alignPair.rangeValue = tagRangeValue; + alignPair.styleValue = @(align); + [alignArr addObject:@(-1)]; + [alignArr addObject:alignPair]; + [processedStyles addObject:alignArr]; + } + if ([tagName isEqualToString:@"b"]) { [styleArr addObject:@([BoldStyle getStyleType])]; } else if ([tagName isEqualToString:@"i"]) { @@ -1408,15 +1491,20 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { [styleArr addObject:@([BlockQuoteStyle getStyleType])]; } else if ([tagName isEqualToString:@"codeblock"]) { [styleArr addObject:@([CodeBlockStyle getStyleType])]; + } else if ([tagName isEqualToString:@"p"]) { + // already processed alignment if present, nothing else to do for + // paragraph } else { // some other external tags like span just don't get put into the // processed styles continue; } - stylePair.rangeValue = tagRangeValue; - [styleArr addObject:stylePair]; - [processedStyles addObject:styleArr]; + if (styleArr.count > 0) { + stylePair.rangeValue = tagRangeValue; + [styleArr addObject:stylePair]; + [processedStyles addObject:styleArr]; + } } return @[ plainText, processedStyles ]; diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index ed6063eac..e9d0c8762 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -78,6 +78,7 @@ export interface EnrichedTextInputInstance extends NativeMethods { text: string, attributes?: Record ) => void; + setParagraphAlignment: (alignment: 'left' | 'center' | 'right') => void; } export interface ContextMenuItem { @@ -379,6 +380,9 @@ export const EnrichedTextInput = ({ setSelection: (start: number, end: number) => { Commands.setSelection(nullthrows(nativeRef.current), start, end); }, + setParagraphAlignment: (alignment: string) => { + Commands.setParagraphAlignment(nullthrows(nativeRef.current), alignment); + }, })); const handleMentionEvent = (e: NativeSyntheticEvent) => { diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 8bf5186aa..e09a94c2a 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; + setParagraphAlignment: ( + viewRef: React.ElementRef, + alignment: string + ) => void; } export const Commands: NativeCommands = codegenNativeCommands({ @@ -493,6 +499,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'startMention', 'addMention', 'requestHTML', + 'setParagraphAlignment', ], });