diff --git a/.maestro/enrichedInput/flows/paragraph_styles_alignment_visual.yaml b/.maestro/enrichedInput/flows/paragraph_styles_alignment_visual.yaml new file mode 100644 index 000000000..d3262dc98 --- /dev/null +++ b/.maestro/enrichedInput/flows/paragraph_styles_alignment_visual.yaml @@ -0,0 +1,46 @@ +appId: swmansion.enriched.example +tags: + - ios-only +--- +# Visually validates text alignment rendering +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'editor-input' + +# Left-aligned text +- inputText: 'Left aligned' +- pressKey: Enter + +# Centre-aligned +- tapOn: + id: 'toolbar-align-center' +- inputText: 'Centre aligned' +- pressKey: Enter + +# Heading 4 +- tapOn: + id: 'toolbar-heading-6' +- inputText: 'Heading 6' +- pressKey: Enter + +# Right-aligned text +- tapOn: + id: 'toolbar-align-right' +- inputText: 'Right aligned' +- pressKey: Enter + +# List with right-aligned items +- tapOn: + id: 'toolbar-ordered-list' +- inputText: 'Element 1' +- pressKey: Enter +- inputText: 'Element 2' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'paragraph_styles_alignment' diff --git a/.maestro/enrichedInput/screenshots/ios/paragraph_styles_alignment.png b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_alignment.png new file mode 100644 index 000000000..28f837a59 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/paragraph_styles_alignment.png differ diff --git a/.maestro/enrichedText/flows/alignment_visual.yaml b/.maestro/enrichedText/flows/alignment_visual.yaml new file mode 100644 index 000000000..cef9fa546 --- /dev/null +++ b/.maestro/enrichedText/flows/alignment_visual.yaml @@ -0,0 +1,30 @@ +appId: swmansion.enriched.example +--- +# Validates that text alignment is displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +

Left aligned

+

Centre aligned

+
Heading 6
+

Right aligned

+
    +
  1. Element 1
  2. +
  3. Element 2
  4. +
+ + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'alignment_visual' diff --git a/.maestro/enrichedText/screenshots/ios/alignment_visual.png b/.maestro/enrichedText/screenshots/ios/alignment_visual.png new file mode 100644 index 000000000..cffcfac4b Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/alignment_visual.png differ diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt index 31d1a7049..f61ef8d60 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt @@ -1,5 +1,6 @@ package com.swmansion.enriched.common.parser +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan import com.swmansion.enriched.common.spans.EnrichedBlockQuoteSpan import com.swmansion.enriched.common.spans.EnrichedBoldSpan import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan @@ -21,6 +22,8 @@ import com.swmansion.enriched.common.spans.EnrichedUnderlineSpan import com.swmansion.enriched.common.spans.EnrichedUnorderedListSpan interface EnrichedSpanFactory { + fun createAlignmentSpan(cssValue: String): EnrichedAlignmentSpan + fun createBoldSpan(style: T): EnrichedBoldSpan fun createItalicSpan(style: T): EnrichedItalicSpan diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedAlignmentSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedAlignmentSpan.kt new file mode 100644 index 000000000..397e7c76f --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedAlignmentSpan.kt @@ -0,0 +1,17 @@ +package com.swmansion.enriched.common.spans + +import android.text.Layout +import android.text.style.AlignmentSpan + +open class EnrichedAlignmentSpan( + val cssValue: String, +) : AlignmentSpan.Standard(cssValueToLayoutAlignment(cssValue)) { + companion object { + fun cssValueToLayoutAlignment(cssValue: String): Layout.Alignment = + when (cssValue) { + "center" -> Layout.Alignment.ALIGN_CENTER + "right" -> Layout.Alignment.ALIGN_OPPOSITE + else -> Layout.Alignment.ALIGN_NORMAL + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt index 373b169fb..d8a3a0f45 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt @@ -1,6 +1,8 @@ package com.swmansion.enriched.text import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.text.spans.EnrichedTextAlignmentSpan import com.swmansion.enriched.text.spans.EnrichedTextBlockQuoteSpan import com.swmansion.enriched.text.spans.EnrichedTextBoldSpan import com.swmansion.enriched.text.spans.EnrichedTextCheckboxListSpan @@ -22,6 +24,8 @@ import com.swmansion.enriched.text.spans.EnrichedTextUnderlineSpan import com.swmansion.enriched.text.spans.EnrichedTextUnorderedListSpan class EnrichedTextSpanFactory : EnrichedSpanFactory { + override fun createAlignmentSpan(cssValue: String) = EnrichedTextAlignmentSpan(cssValue) + override fun createBoldSpan(style: EnrichedTextStyle) = EnrichedTextBoldSpan(style) override fun createItalicSpan(style: EnrichedTextStyle) = EnrichedTextItalicSpan(style) diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextAlignmentSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextAlignmentSpan.kt new file mode 100644 index 000000000..12f455294 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextAlignmentSpan.kt @@ -0,0 +1,7 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan + +class EnrichedTextAlignmentSpan( + cssValue: String, +) : EnrichedAlignmentSpan(cssValue) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt index 0d776c8ed..3c75e042a 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt @@ -1,8 +1,10 @@ package com.swmansion.enriched.textinput import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan import com.swmansion.enriched.common.spans.EnrichedImageSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan import com.swmansion.enriched.textinput.spans.EnrichedInputBlockQuoteSpan import com.swmansion.enriched.textinput.spans.EnrichedInputBoldSpan import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan @@ -25,6 +27,8 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan import com.swmansion.enriched.textinput.styles.HtmlStyle class EnrichedTextInputSpannableFactory : EnrichedSpanFactory { + override fun createAlignmentSpan(cssValue: String) = EnrichedInputAlignmentSpan(cssValue) + override fun createBoldSpan(style: HtmlStyle) = EnrichedInputBoldSpan(style) override fun createItalicSpan(style: HtmlStyle) = EnrichedInputItalicSpan(style) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index 885a12ea4..18750b731 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -57,6 +57,7 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputImageSpan import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan +import com.swmansion.enriched.textinput.styles.AlignmentStyles import com.swmansion.enriched.textinput.styles.HtmlStyle import com.swmansion.enriched.textinput.styles.InlineStyles import com.swmansion.enriched.textinput.styles.ListStyles @@ -85,6 +86,7 @@ class EnrichedTextInputView : val paragraphStyles: ParagraphStyles? = ParagraphStyles(this) val listStyles: ListStyles? = ListStyles(this) val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this) + val alignmentStyles: AlignmentStyles? = AlignmentStyles(this) var isDuringTransaction: Boolean = false var isRemovingMany: Boolean = false var scrollEnabled: Boolean = true @@ -962,6 +964,14 @@ class EnrichedTextInputView : parametrizedStyles?.setMentionSpan(text, indicator, attributes) } + fun setTextAlignment(alignment: String) { + runAsATransaction { + alignmentStyles?.setAlignment(alignment) + } + selection?.validateStyles() + layoutManager.invalidateLayout() + } + fun requestHTML(requestId: Int) { val html = try { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 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/spans/EnrichedInputAlignmentSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputAlignmentSpan.kt new file mode 100644 index 000000000..5f2f59615 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputAlignmentSpan.kt @@ -0,0 +1,7 @@ +package com.swmansion.enriched.textinput.spans + +import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan + +class EnrichedInputAlignmentSpan( + cssValue: String, +) : EnrichedAlignmentSpan(cssValue) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt new file mode 100644 index 000000000..ffd2e8ca8 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/AlignmentStyles.kt @@ -0,0 +1,247 @@ +package com.swmansion.enriched.textinput.styles + +import android.text.Editable +import android.text.Spannable +import android.text.SpannableStringBuilder +import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.textinput.EnrichedTextInputView +import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan +import com.swmansion.enriched.textinput.utils.getParagraphBounds +import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries + +class AlignmentStyles( + private val view: EnrichedTextInputView, +) { + private fun toCssValue(alignment: String): String? = + when (alignment) { + "center" -> "center" + "right" -> "right" + "left" -> "left" + else -> null + } + + // MARK: - Text Watcher Entry Point + + fun afterTextChanged( + s: Editable, + cursorPosition: Int, + deletedText: String, + ) { + if (s.isEmpty()) { + handleEmptyDocumentReset(s) + return + } + + val isNewLineInserted = cursorPosition > 0 && cursorPosition <= s.length && s[cursorPosition - 1] == '\n' && deletedText.isEmpty() + val includesNewlineDeletion = deletedText.contains('\n') + val isZwsDeleted = deletedText == EnrichedConstants.ZWS_STRING + + view.runAsATransaction { + if (isZwsDeleted) { + handleZwsBackspace(s, cursorPosition) + } else if (includesNewlineDeletion) { + handleParagraphMerge(s, cursorPosition) + } else if (isNewLineInserted) { + handleNewlineInheritance(s, cursorPosition) + } + } + + view.selection?.validateStyles() + } + + // MARK: - Alignment Toolbar Actions + + fun setAlignment(alignment: String) { + val spannable = view.text as? SpannableStringBuilder ?: return + val selection = view.selection ?: return + + val (start, end) = selection.getParagraphSelection() + val cssValue = toCssValue(alignment) + + var shiftedEnd = end + var cursor = start + + while (cursor <= shiftedEnd) { + val (paraStart, paraEnd) = spannable.getParagraphBounds(cursor) + + cleanUpExistingSpans(spannable, paraStart, paraEnd) + + if (cssValue != null) { + if (paraStart == paraEnd) { + // Empty line: Insert ZWS anchor + spannable.insert(paraStart, EnrichedConstants.ZWS_STRING) + spannable.setSpan( + EnrichedInputAlignmentSpan(cssValue), + paraStart, + paraStart + 1, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE, + ) + + shiftedEnd++ // Document grew, expand loop boundary + + if (paraStart + 1 >= shiftedEnd) break + cursor = paraStart + 2 + continue + } else { + // Standard text paragraph + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(paraStart, paraEnd) + if (safeStart < safeEnd) { + spannable.setSpan( + EnrichedInputAlignmentSpan(cssValue), + safeStart, + safeEnd, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE, + ) + } + } + } + + if (paraEnd >= shiftedEnd || paraEnd == spannable.length) break + cursor = paraEnd + 1 + } + + // Nudge the cursor to force Android to redraw it at the new aligned position + view.setSelection(view.selection.start, view.selection.end) + } + + fun getCurrentAlignment(): String { + val spannable = view.text as? Spannable ?: return "left" + val selection = view.selection ?: return "left" + + val cursorPos = selection.start.coerceAtLeast(0).coerceAtMost(spannable.length) + val (paraStart, paraEnd) = spannable.getParagraphBounds(cursorPos) + val spans = spannable.getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + + return spans.firstOrNull()?.cssValue ?: "left" + } + + // MARK: - Private Handlers + + /** + * Resets the entire input to the default state when all text is deleted. + */ + private fun handleEmptyDocumentReset(s: Editable) { + view.runAsATransaction { + val spans = s.getSpans(0, 0, EnrichedInputAlignmentSpan::class.java) + spans.forEach { s.removeSpan(it) } + view.setSelection(0) + } + view.selection?.validateStyles() + } + + /** + * Handles backspacing a Zero Width Space. Deletes the preceding newline to merge upward, + * or clears the alignment if at the very beginning of the document. + */ + private fun handleZwsBackspace( + s: Editable, + cursorPosition: Int, + ) { + if (cursorPosition > 0 && s[cursorPosition - 1] == '\n') { + // Clean up orphaned span from the bottom line before merging + val (currentParaStart, currentParaEnd) = s.getParagraphBounds(cursorPosition) + s + .getSpans(currentParaStart, currentParaEnd, EnrichedInputAlignmentSpan::class.java) + .forEach { s.removeSpan(it) } + + // Delete the newline to jump to the previous line + s.delete(cursorPosition - 1, cursorPosition) + view.setSelection(cursorPosition - 1) + } else if (cursorPosition == 0) { + // First line cleanup + val (paraStart, paraEnd) = s.getParagraphBounds(0) + s + .getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + .forEach { s.removeSpan(it) } + } + } + + /** + * Resolves Conflicting Spans when paragraphs are merged manually by the user + * (e.g., deleting a newline or deleting a highlighted block across paragraphs). + */ + private fun handleParagraphMerge( + s: Editable, + cursorPosition: Int, + ) { + val (paraStart, paraEnd) = s.getParagraphBounds(cursorPosition) + val spans = s.getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + + var dominantTopSpan: EnrichedInputAlignmentSpan? = null + + spans.forEach { span -> + if (s.getSpanStart(span) >= cursorPosition) { + // Orphan span from a pulled-up bottom paragraph. Kill it. + s.removeSpan(span) + } else { + // This span belongs to the top paragraph. + dominantTopSpan = span + } + } + + // Stretch the top paragraph's span to cover the newly merged text. + dominantTopSpan?.let { + s.setSpan(it, paraStart, paraEnd, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } + } + + /** + * Propagates the alignment of the previous paragraph to a newly created line. + */ + private fun handleNewlineInheritance( + s: Editable, + cursorPosition: Int, + ) { + val (prevParaStart, prevParaEnd) = s.getParagraphBounds(cursorPosition - 1) + val prevSpan = + s + .getSpans(prevParaStart, prevParaEnd, EnrichedInputAlignmentSpan::class.java) + .firstOrNull() ?: return + + val (newParaStart, newParaEnd) = s.getParagraphBounds(cursorPosition) + + if (newParaStart == newParaEnd) { + // Empty new line — insert ZWS anchor + s.insert(cursorPosition, EnrichedConstants.ZWS_STRING) + s.setSpan( + EnrichedInputAlignmentSpan(prevSpan.cssValue), + cursorPosition, + cursorPosition + 1, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE, + ) + view.setSelection(cursorPosition + 1) + } else { + // Cursor was moved mid-sentence and Enter was pressed + s.setSpan( + EnrichedInputAlignmentSpan(prevSpan.cssValue), + newParaStart, + newParaEnd, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE, + ) + } + } + + /** + * Surgically removes spans from a specific paragraph bounds, splitting any global + * spans that leaked outside the target paragraph. + */ + private fun cleanUpExistingSpans( + spannable: SpannableStringBuilder, + paraStart: Int, + paraEnd: Int, + ) { + val existing = spannable.getSpans(paraStart, paraEnd, EnrichedInputAlignmentSpan::class.java) + for (span in existing) { + val sStart = spannable.getSpanStart(span) + val sEnd = spannable.getSpanEnd(span) + spannable.removeSpan(span) + + if (sStart < paraStart) { + spannable.setSpan(EnrichedInputAlignmentSpan(span.cssValue), sStart, paraStart, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } + if (sEnd > paraEnd) { + spannable.setSpan(EnrichedInputAlignmentSpan(span.cssValue), paraEnd, sEnd, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } + } + } +} 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..44be14329 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt @@ -104,11 +104,15 @@ class EnrichedSelection( for ((style, config) in EnrichedSpans.parametrizedStyles) { state.setStart(style, getParametrizedStyleStart(config.clazz)) } + + val currentAlignment = view.alignmentStyles?.getCurrentAlignment() ?: "left" + state.setAlignment(currentAlignment) } fun getInlineSelection(): Pair { - val finalStart = start.coerceAtMost(end).coerceAtLeast(0) - val finalEnd = end.coerceAtLeast(start).coerceAtLeast(0) + val textLength = view.text?.length ?: 0 + val finalStart = start.coerceAtMost(end).coerceAtLeast(0).coerceAtMost(textLength) + val finalEnd = end.coerceAtLeast(start).coerceAtLeast(0).coerceAtMost(textLength) return Pair(finalStart, finalEnd) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt index 67e9f9b15..05d68871f 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,15 @@ class EnrichedSpanState( private set var mentionStart: Int? = null private set + var currentAlignment: String = "left" + private set + + fun setAlignment(value: String) { + if (currentAlignment != value) { + currentAlignment = value + emitStateChangeEvent() + } + } fun setBoldStart(start: Int?) { this.boldStart = start @@ -246,6 +255,7 @@ class EnrichedSpanState( payload.putMap("image", getStyleState(activeStyles, EnrichedSpans.IMAGE)) payload.putMap("mention", getStyleState(activeStyles, EnrichedSpans.MENTION)) payload.putMap("checkboxList", getStyleState(activeStyles, EnrichedSpans.CHECKBOX_LIST)) + payload.putString("alignment", currentAlignment) return payload } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index 028b41c15..626e76149 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 @@ -14,6 +14,9 @@ class EnrichedTextWatcher( private var startCursorPosition: Int = 0 private var previousTextLength: Int = 0 + // Track exactly what text is being deleted + private var deletedText: String = "" + override fun beforeTextChanged( s: CharSequence?, start: Int, @@ -21,6 +24,8 @@ class EnrichedTextWatcher( after: Int, ) { previousTextLength = s?.length ?: 0 + // If count > 0, text is being deleted or replaced. Capture it. + deletedText = if (count > 0 && s != null) s.substring(start, start + count) else "" } override fun onTextChanged( @@ -47,6 +52,7 @@ class EnrichedTextWatcher( view.inlineStyles?.afterTextChanged(s, endCursorPosition) view.paragraphStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) view.listStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) + view.alignmentStyles?.afterTextChanged(s, endCursorPosition, deletedText) view.parametrizedStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) } 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 c95a1224a..196377a03 100644 --- a/apps/example/src/constants/editorConfig.ts +++ b/apps/example/src/constants/editorConfig.ts @@ -32,6 +32,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 04c8d03ed..c58a3f416 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -190,4 +190,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/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index d28cce6cf..f41fecc61 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -292,12 +292,14 @@ 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 | |-------------------------------------------------------------|----------| @@ -604,6 +606,16 @@ 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 text alignment for the paragraph(s) at the current selection. When inside a list, the alignment is applied to all contiguous list items. + +- `alignment` - the desired text alignment. Use `'default'` to reset to the system natural alignment. + ### `.startMention()` ```ts diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index f7889097c..fb05354c2 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,4 +1,5 @@ #import "EnrichedTextInputView.h" +#import "AlignmentUtils.h" #import "AttachmentLayoutUtils.h" #import "CoreText/CoreText.h" #import "DotReplacementUtils.h" @@ -58,6 +59,7 @@ @implementation EnrichedTextInputView { NSArray *_contextMenuItems; NSString *_submitBehavior; NSDictionary *_capturedAttributesBeforeChange; + NSString *_recentlyEmittedAlignment; } @synthesize blockEmitting = blockEmitting; @@ -128,6 +130,7 @@ - (void)setDefaults { _blockedStyles = [[NSMutableSet alloc] init]; _recentlyActiveLinkRange = NSMakeRange(0, 0); _recentlyActiveMentionRange = NSMakeRange(0, 0); + _recentlyEmittedAlignment = @"left"; _recentInputString = @""; _recentlyEmittedHtml = @"\n

\n"; _emitHtml = NO; @@ -843,6 +846,20 @@ - (void)refreshPlaceholderLabelStyles { if (_placeholderColor != nullptr) { newAttrs[NSForegroundColorAttributeName] = _placeholderColor; } + + // Get the current active alignment in input + NSParagraphStyle *currentTypingPara = + textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment activeAlignment = + currentTypingPara ? currentTypingPara.alignment : NSTextAlignmentNatural; + NSMutableParagraphStyle *placeholderPStyle = + [newAttrs[NSParagraphStyleAttributeName] mutableCopy]; + if (!placeholderPStyle) { + placeholderPStyle = [[NSMutableParagraphStyle alloc] init]; + } + placeholderPStyle.alignment = activeAlignment; + newAttrs[NSParagraphStyleAttributeName] = placeholderPStyle; + NSAttributedString *newAttrStr = [[NSAttributedString alloc] initWithString:_placeholderLabel.text attributes:newAttrs]; @@ -1052,12 +1069,20 @@ - (void)tryUpdatingActiveStyles { } } + // detect alignment change + AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])]; + NSString *currentAlignment = [alignmentStyle getStyleState]; + 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]), @@ -1078,7 +1103,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]}); } } @@ -1203,6 +1229,20 @@ - (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]; + + AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])]; + [alignmentStyle + addAlignment:[AlignmentUtils stringToAlignment:alignmentString] + range:textView.selectedRange + withTyping:YES + withDirtyRange:YES]; + + [self anyTextMayHaveBeenModified]; + if (!_placeholderLabel.isHidden) { + [self refreshPlaceholderLabelStyles]; + } } } @@ -1768,6 +1808,9 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText { [textView.textStorage.string substringWithRange:selectedRange]; } + AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])]; + NSString *currentAlignment = [alignmentStyle getStyleState]; + emitter->onContextMenuItemPress( {.itemText = [itemText toCppString], .selectedText = [selectedText toCppString], @@ -1793,7 +1836,8 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText { .link = GET_STYLE_STATE([LinkStyle getType]), .image = GET_STYLE_STATE([ImageStyle getType]), .mention = GET_STYLE_STATE([MentionStyle getType]), - .checkboxList = GET_STYLE_STATE([CheckboxListStyle getType])}}); + .checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]), + .alignment = [currentAlignment UTF8String]}}); } } diff --git a/ios/enrichedInputTextView/EnrichedInputTextView/EnrichedInputTextView.mm b/ios/enrichedInputTextView/EnrichedInputTextView/EnrichedInputTextView.mm index 6d19c5655..009674cf2 100644 --- a/ios/enrichedInputTextView/EnrichedInputTextView/EnrichedInputTextView.mm +++ b/ios/enrichedInputTextView/EnrichedInputTextView/EnrichedInputTextView.mm @@ -1,8 +1,10 @@ #import "EnrichedInputTextView.h" +#import "AlignmentUtils.h" #import "EnrichedTextInputView.h" #import "HtmlParser.h" #import "StringExtension.h" #import "TextInsertionUtils.h" +#import "TextListUtils.h" #import @implementation EnrichedInputTextView @@ -18,6 +20,48 @@ - (void)layoutSubviews { } } +// UITextView places the cursor at the leading edge when a paragraph contains +// zero (or invisible) glyphs because the layout engine has nothing to align. +// We fix this by reading the active alignment and repositioning the caret rect +- (CGRect)caretRectForPosition:(UITextPosition *)position { + CGRect rect = [super caretRectForPosition:position]; + NSUInteger idx = [self offsetFromPosition:self.beginningOfDocument + toPosition:position]; + NSString *text = self.textStorage.string; + NSRange paraRange = [text paragraphRangeForRange:NSMakeRange(idx, 0)]; + + // Determine whether the paragraph contains no visible characters. + BOOL isEmpty = NO; + if (paraRange.length == 0) { + isEmpty = YES; + } + + if (!isEmpty) { + return rect; + } + + NSParagraphStyle *pStyle = + self.typingAttributes[NSParagraphStyleAttributeName]; + + if (pStyle == nil) { + return rect; + } + + NSString *marker = [TextListUtils firstTextListWithPrefix:@"EnrichedAlignment" + inArray:pStyle.textLists] + .markerFormat; + NSTextAlignment alignment = [AlignmentUtils markerToAlignment:marker]; + CGFloat containerWidth = self.textContainer.size.width; + + if (alignment == NSTextAlignmentCenter) { + rect.origin.x = (containerWidth - rect.size.width) / 2.0; + } else if (alignment == NSTextAlignmentRight) { + rect.origin.x = containerWidth - rect.size.width; + } + + return rect; +} + - (void)copy:(id)sender { EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input; if (typedInput == nullptr) { diff --git a/ios/extensions/LayoutManagerExtension.mm b/ios/extensions/LayoutManagerExtension.mm index 5a9b1797c..234d37295 100644 --- a/ios/extensions/LayoutManagerExtension.mm +++ b/ios/extensions/LayoutManagerExtension.mm @@ -264,6 +264,7 @@ - (void)drawLists:(id)host NSFontAttributeName : [host.config orderedListMarkerFont], NSForegroundColorAttributeName : [host.config orderedListMarkerColor] }; + CGFloat indent = pStyle.firstLineHeadIndent; NSArray *paragraphs = [RangeUtils getSeparateParagraphsRangesIn:host.textView @@ -274,57 +275,69 @@ - (void)drawLists:(id)host [self glyphRangeForCharacterRange:[paragraph rangeValue] actualCharacterRange:nullptr]; - [self - enumerateLineFragmentsForGlyphRange:paragraphGlyphRange - usingBlock:^(CGRect rect, CGRect usedRect, - NSTextContainer *container, - NSRange lineGlyphRange, - BOOL *stop) { - NSUInteger charIdx = - [self characterIndexForGlyphAtIndex: - lineGlyphRange.location]; - UIFont *font = [host.textView.textStorage - attribute:NSFontAttributeName - atIndex:charIdx - effectiveRange:nil]; - CGRect textUsedRect = - [self getTextAlignedUsedRect:usedRect - font:font]; - - NSString *markerFormat = - pStyle.textLists.firstObject - .markerFormat; - - if ([markerFormat - isEqualToString: - @"EnrichedOrderedList"]) { - NSString *marker = [self - getDecimalMarkerForList:host - charIndex:charIdx]; - [self drawDecimal:host - marker:marker - markerAttributes:markerAttributes + [self enumerateLineFragmentsForGlyphRange:paragraphGlyphRange + usingBlock:^(CGRect rect, CGRect usedRect, + NSTextContainer *container, + NSRange lineGlyphRange, + BOOL *stop) { + NSUInteger charIdx = + [self characterIndexForGlyphAtIndex: + lineGlyphRange.location]; + UIFont *font = [host.textView.textStorage + attribute:NSFontAttributeName + atIndex:charIdx + effectiveRange:nil]; + CGRect textUsedRect = + [self getTextAlignedUsedRect:usedRect + font:font]; + + for (NSTextList *list in pStyle + .textLists) { + NSString *markerFormat = + list.markerFormat; + + if ([markerFormat + hasPrefix: + @"EnrichedAlignment"]) { + continue; + } + + if ([markerFormat + isEqualToString: + @"EnrichedOrderedList"]) { + NSString *marker = [self + getDecimalMarkerForList:host + charIndex:charIdx]; + [self drawDecimal:host + marker:marker + markerAttributes:markerAttributes + origin:origin + usedRect:usedRect + indent:indent]; + } else if ([markerFormat + isEqualToString: + @"EnrichedUnordered" + @"Lis" + @"t"]) { + [self drawBullet:host origin:origin - usedRect:usedRect]; - } else if ([markerFormat - isEqualToString: - @"EnrichedUnorderedLis" - @"t"]) { - [self drawBullet:host - origin:origin - usedRect:textUsedRect]; - } else if ([markerFormat - hasPrefix: - @"EnrichedCheckbox"]) { - [self drawCheckbox:host - markerFormat:markerFormat - origin:origin - usedRect:textUsedRect]; - } - // only first line of a list gets its - // marker drawn - *stop = YES; - }]; + usedRect:textUsedRect + indent:indent]; + + } else if ([markerFormat + hasPrefix:@"EnrichedChe" + @"ckbox"]) { + [self drawCheckbox:host + markerFormat:markerFormat + origin:origin + usedRect:textUsedRect + indent:indent]; + } + } + // only first line of a list gets its + // marker drawn + *stop = YES; + }]; } } } @@ -386,7 +399,8 @@ - (CGRect)getTextAlignedUsedRect:(CGRect)usedRect font:(UIFont *)font { - (void)drawCheckbox:(id)host markerFormat:(NSString *)markerFormat origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { BOOL isChecked = [markerFormat isEqualToString:@"EnrichedCheckbox1"]; UIImage *image = isChecked ? host.config.checkboxCheckedImage @@ -395,7 +409,7 @@ - (void)drawCheckbox:(id)host CGFloat boxSize = [host.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)]; @@ -403,10 +417,11 @@ - (void)drawCheckbox:(id)host - (void)drawBullet:(id)host origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { CGFloat gapWidth = [host.config unorderedListGapWidth]; CGFloat bulletSize = [host.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(); @@ -424,10 +439,11 @@ - (void)drawDecimal:(id)host marker:(NSString *)marker markerAttributes:(NSDictionary *)markerAttributes origin:(CGPoint)origin - usedRect:(CGRect)usedRect { + usedRect:(CGRect)usedRect + indent:(CGFloat)indent { CGFloat gapWidth = [host.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/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index f315add17..97d4a481e 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1,4 +1,6 @@ #import "HtmlParser.h" +#import "AlignmentEntry.h" +#import "AlignmentUtils.h" #import "ImageData.h" #import "LinkData.h" #import "MentionParams.h" @@ -442,6 +444,8 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml { NSMutableDictionary *ongoingTags = [[NSMutableDictionary alloc] init]; NSMutableArray *initiallyProcessedTags = [[NSMutableArray alloc] init]; NSMutableDictionary *checkboxStates = [[NSMutableDictionary alloc] init]; + NSMutableArray *foundAlignments = + [[NSMutableArray alloc] init]; BOOL insideCheckboxList = NO; NSInteger precedingImageCount = 0; BOOL insideTag = NO; @@ -482,8 +486,7 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)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 @@ -492,6 +495,13 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)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, the current // precedingImageCount and optionally params and put them under tag name // key in ongoingTags. Storing the open-time image count lets @@ -549,6 +559,10 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml { mutableCopy]; } + [self checkForAlignments:ongoingTags[currentTagName] + plainText:plainText + foundAlignments:foundAlignments + precedingImageCount:precedingImageCount]; [self finalizeTagEntry:currentTagName ongoingTags:ongoingTags initiallyProcessedTags:initiallyProcessedTags @@ -781,7 +795,7 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml { [processedStyles addObject:styleArr]; } - return @[ plainText, processedStyles ]; + return @[ plainText, processedStyles, foundAlignments ]; } + (NSString *)parseToHtmlFromRange:(NSRange)range @@ -813,6 +827,10 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range // check each existing style existence for (NSNumber *type in host.stylesDict) { StyleBase *style = host.stylesDict[type]; + // we do not want to add <> tags for alignment + if ([style isKindOfClass:[AlignmentStyle class]]) { + continue; + } if ([style detect:currentRange]) { [currentActiveStyles addObject:type]; @@ -979,19 +997,26 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range [result appendString:@"\n"]; } + NSString *cssStyleString = + [self prepareCssStyleString:currentRange.location + isOpeningTag:YES + host:host]; + // handle starting unordered list if (!inUnorderedList && [currentActiveStyles containsObject:@([UnorderedListStyle getType])]) { inUnorderedList = YES; - [result appendString:@"\n
    "]; + [result appendString:[NSString stringWithFormat:@"\n", + cssStyleString]]; } // handle starting ordered list if (!inOrderedList && [currentActiveStyles containsObject:@([OrderedListStyle getType])]) { inOrderedList = YES; - [result appendString:@"\n
      "]; + [result appendString:[NSString stringWithFormat:@"\n", + cssStyleString]]; } // handle starting blockquotes if (!inBlockQuote && @@ -1010,7 +1035,9 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range [currentActiveStyles containsObject:@([CheckboxListStyle getType])]) { inCheckboxList = YES; - [result appendString:@"\n
        "]; + [result appendString:[NSString stringWithFormat: + @"\n
          ", + cssStyleString]]; } // don't add the

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

          "]; + [result appendString:[NSString + stringWithFormat:@"", cssStyleString]]; } } @@ -1239,6 +1267,9 @@ + (NSString *)tagContentForStyle:(NSNumber *)style openingTag:(BOOL)openingTag location:(NSInteger)location host:(id)host { + NSString *cssStyleString = [self prepareCssStyleString:location + isOpeningTag:openingTag + host:host]; if ([style isEqualToNumber:@([BoldStyle getType])]) { return @"b"; } else if ([style isEqualToNumber:@([ItalicStyle getType])]) { @@ -1318,17 +1349,17 @@ + (NSString *)tagContentForStyle:(NSNumber *)style return @"mention"; } } else if ([style isEqualToNumber:@([H1Style getType])]) { - return @"h1"; + return [NSString stringWithFormat:@"h1%@", cssStyleString]; } else if ([style isEqualToNumber:@([H2Style getType])]) { - return @"h2"; + return [NSString stringWithFormat:@"h2%@", cssStyleString]; } else if ([style isEqualToNumber:@([H3Style getType])]) { - return @"h3"; + return [NSString stringWithFormat:@"h3%@", cssStyleString]; } else if ([style isEqualToNumber:@([H4Style getType])]) { - return @"h4"; + return [NSString stringWithFormat:@"h4%@", cssStyleString]; } else if ([style isEqualToNumber:@([H5Style getType])]) { - return @"h5"; + return [NSString stringWithFormat:@"h5%@", cssStyleString]; } else if ([style isEqualToNumber:@([H6Style getType])]) { - return @"h6"; + return [NSString stringWithFormat:@"h6%@", cssStyleString]; } else if ([style isEqualToNumber:@([UnorderedListStyle getType])] || [style isEqualToNumber:@([OrderedListStyle getType])]) { return @"li"; @@ -1348,9 +1379,96 @@ + (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%@", cssStyleString]; + } + return @""; +} + ++ (NSString *)cssValueForAlignment:(NSTextAlignment)alignment { + switch (alignment) { + case NSTextAlignmentCenter: + return @"center"; + case NSTextAlignmentRight: + return @"right"; + case NSTextAlignmentJustified: + return @"justify"; + default: + return nil; + } +} + ++ (NSString *)prepareCssStyleString:(NSInteger)location + isOpeningTag:(BOOL)isOpeningTag + host:(id)host { + if (!isOpeningTag) { + return @""; + } + + NSParagraphStyle *pStyle = + [host.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]; + return [AlignmentUtils stringToAlignment:value]; + } + + return NSTextAlignmentNatural; +} + ++ (void)checkForAlignments:(NSArray *)tagData + plainText:(NSString *)plainText + foundAlignments:(NSMutableArray *)foundAlignments + precedingImageCount:(NSInteger)precedingImageCount { + if (tagData == nil) { + return; + } + + // We look at the params stored in ongoingTags + NSString *storedParams = (tagData.count > 2) ? tagData[2] : 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/inputAttributesManager/InputAttributesManager.mm b/ios/inputAttributesManager/InputAttributesManager.mm index 23f9ee645..15dcba2f1 100644 --- a/ios/inputAttributesManager/InputAttributesManager.mm +++ b/ios/inputAttributesManager/InputAttributesManager.mm @@ -1,8 +1,10 @@ #import "InputAttributesManager.h" +#import "AlignmentUtils.h" #import "AttributeEntry.h" #import "EnrichedTextInputView.h" #import "RangeUtils.h" #import "StyleHeaders.h" +#import "ZeroWidthSpaceUtils.h" @implementation InputAttributesManager { NSMutableArray *_dirtyRanges; @@ -87,6 +89,11 @@ - (void)handleDirtyRangesStyling { [_input->textView.textStorage setAttributes:_input->defaultTypingAttributes range:dirtyRange]; + // Restore ZWS layout metadata that is stored in regular attributes and was + // overwritten by the default-attributes reset above. + [ZeroWidthSpaceUtils applyKernForZeroWidthSpacesInRange:dirtyRange + host:_input]; + // Sort style types so paragraph styles come first. Their broad visual // attributes (e.g. foreground color, font) are laid down before inline // styles override them on their specific sub-ranges. @@ -124,8 +131,8 @@ - (void)manageTypingAttributesWithOnlySelection:(BOOL)onlySelectionChanged { EnrichedInputTextView *textView = _input->textView; NSRange selectedRange = textView.selectedRange; - // Typing attributes get reset when only selection changed to an empty line - // (or empty line with newline). + // Typing attributes get reset (except alignment) when only selection changed + // to an empty line (or empty line with newline). if (onlySelectionChanged) { NSRange paragraphRange = [textView.textStorage.string paragraphRangeForRange:selectedRange]; @@ -136,7 +143,29 @@ - (void)manageTypingAttributesWithOnlySelection:(BOOL)onlySelectionChanged { characterIsMember:[textView.textStorage.string characterAtIndex:paragraphRange .location]])) { - textView.typingAttributes = _input->defaultTypingAttributes; + NSParagraphStyle *currentTypingStyle = + textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment savedAlignment = currentTypingStyle + ? currentTypingStyle.alignment + : NSTextAlignmentNatural; + + NSMutableDictionary *newAttrs = + [_input->defaultTypingAttributes mutableCopy]; + + NSParagraphStyle *defaultPStyle = newAttrs[NSParagraphStyleAttributeName]; + NSMutableParagraphStyle *paraStyle = + defaultPStyle ? [defaultPStyle mutableCopy] + : [[NSMutableParagraphStyle alloc] init]; + + // preserve alignment + paraStyle.alignment = savedAlignment; + NSString *markerFormat = + [AlignmentUtils alignmentToMarker:savedAlignment]; + paraStyle.textLists = + @[ [[NSTextList alloc] initWithMarkerFormat:markerFormat options:0] ]; + + newAttrs[NSParagraphStyleAttributeName] = paraStyle; + textView.typingAttributes = newAttrs; return; } } @@ -155,7 +184,7 @@ - (void)manageTypingAttributesWithOnlySelection:(BOOL)onlySelectionChanged { NSParagraphStyle *pStyle = (NSParagraphStyle *)_input->textView .typingAttributes[NSParagraphStyleAttributeName]; - if (pStyle != nullptr && pStyle.textLists.count == 1) { + if (pStyle != nullptr && pStyle.textLists.count >= 1) { NSMutableParagraphStyle *newPStyle = [[NSMutableParagraphStyle alloc] init]; newPStyle.textLists = pStyle.textLists; diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index f7ca534b0..eb484b6be 100644 --- a/ios/inputHtmlParser/InputHtmlParser.mm +++ b/ios/inputHtmlParser/InputHtmlParser.mm @@ -1,4 +1,5 @@ #import "InputHtmlParser.h" +#import "AlignmentEntry.h" #import "EnrichedTextInputView.h" #import "HtmlParser.h" #import "StringExtension.h" @@ -18,8 +19,6 @@ - (instancetype)initWithInput:(id)input { } - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { - NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; - // reset the text first and reset typing attributes _input->textView.text = @""; _input->textView.typingAttributes = _input->defaultTypingAttributes; @@ -28,6 +27,7 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; + NSArray *alignments = (NSArray *)processingResult[2]; // set new text _input->textView.text = plainText; @@ -36,6 +36,7 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { [self applyProcessedStyles:stylesInfo offsetFromBeginning:0 plainTextLength:plainText.length]; + [self applyProcessedAlignments:alignments offset:0]; } @catch (NSException *exception) { RCTLogWarn(@"[EnrichedTextInput]: Failed to parse HTML: (%@), falling back " @"to raw input.", @@ -51,6 +52,7 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { NSArray *processingResult = [HtmlParser 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 @@ -62,6 +64,7 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { [self applyProcessedStyles:stylesInfo offsetFromBeginning:range.location plainTextLength:plainText.length]; + [self applyProcessedAlignments:alignments offset:range.location]; } @catch (NSException *exception) { RCTLogWarn(@"[EnrichedTextInput]: Failed to parse HTML: (%@), falling back " @"to raw input.", @@ -79,6 +82,7 @@ - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { NSArray *processingResult = [HtmlParser 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 @@ -90,6 +94,7 @@ - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { [self applyProcessedStyles:stylesInfo offsetFromBeginning:location plainTextLength:plainText.length]; + [self applyProcessedAlignments:alignments offset:location]; } @catch (NSException *exception) { RCTLogWarn(@"[EnrichedTextInput]: Failed to parse HTML: (%@), falling back " @"to raw input.", @@ -189,6 +194,27 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles [_input anyTextMayHaveBeenModified]; } +- (void)applyProcessedAlignments:(NSArray *)alignments + offset:(NSInteger)offset { + AlignmentStyle *alignmentStyle = + _input.stylesDict[@([AlignmentStyle getType])]; + + if (alignmentStyle == nil) { + return; + } + + 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); + + [alignmentStyle addAlignment:entry.alignment + range:finalRange + withTyping:NO + withDirtyRange:NO]; + } +} + - (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html { return [HtmlParser initiallyProcessHtml:html useHtmlNormalizer:_input->useHtmlNormalizer]; 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/interfaces/EnrichedTextStyleHeaders.h b/ios/interfaces/EnrichedTextStyleHeaders.h index 9c717aaea..008f0fce4 100644 --- a/ios/interfaces/EnrichedTextStyleHeaders.h +++ b/ios/interfaces/EnrichedTextStyleHeaders.h @@ -57,3 +57,6 @@ @interface EnrichedTextCheckboxListStyle : CheckboxListStyle @end + +@interface EnrichedTextAlignmentStyle : AlignmentStyle +@end diff --git a/ios/interfaces/StyleBase.h b/ios/interfaces/StyleBase.h index d4eae108f..41a3d27de 100644 --- a/ios/interfaces/StyleBase.h +++ b/ios/interfaces/StyleBase.h @@ -10,6 +10,7 @@ + (StyleType)getType; - (NSString *)getKey; - (NSString *)getValue; +- (NSString *)getMarkerPrefix; - (BOOL)isParagraph; - (BOOL)needsZWS; - (BOOL)appliesStylingToTyping; diff --git a/ios/interfaces/StyleBase.mm b/ios/interfaces/StyleBase.mm index 15853514b..2ba5bbcb2 100644 --- a/ios/interfaces/StyleBase.mm +++ b/ios/interfaces/StyleBase.mm @@ -2,6 +2,7 @@ #import "AttributeEntry.h" #import "OccurenceUtils.h" #import "RangeUtils.h" +#import "TextListUtils.h" #import "ZeroWidthSpaceUtils.h" @implementation StyleBase @@ -25,6 +26,12 @@ - (NSString *)getValue { return @"AnyValue"; } +// Paragraph styles that store a family of mutually exclusive markers (e.g. +// alignment variants) should override this to return the shared prefix. +- (NSString *)getMarkerPrefix { + return nil; +} + // This method gets overridden - (BOOL)isParagraph { return false; @@ -94,8 +101,9 @@ - (void)add:(NSRange)range if (pStyle == nullptr) return; pStyle.textLists = - @[ [[NSTextList alloc] initWithMarkerFormat:value - options:0] ]; + [TextListUtils textListsByAdding:value + withExclusivePrefix:[self getMarkerPrefix] + toArray:pStyle.textLists]; [self.host.textView.textStorage addAttribute:NSParagraphStyleAttributeName value:pStyle @@ -130,7 +138,10 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { [(NSParagraphStyle *)existingValue mutableCopy]; if (pStyle == nullptr) return; - pStyle.textLists = @[]; + pStyle.textLists = + [TextListUtils textListsByRemoving:[self getValue] + withPrefix:[self getMarkerPrefix] + fromArray:pStyle.textLists]; [self.host.textView.textStorage addAttribute:NSParagraphStyleAttributeName value:pStyle @@ -154,8 +165,9 @@ - (void)addTypingWithValue:(NSString *)value { } else { NSMutableParagraphStyle *pStyle = [newTypingAttrs[NSParagraphStyleAttributeName] mutableCopy]; - pStyle.textLists = @[ [[NSTextList alloc] initWithMarkerFormat:value - options:0] ]; + pStyle.textLists = [TextListUtils textListsByAdding:value + withExclusivePrefix:[self getMarkerPrefix] + toArray:pStyle.textLists]; newTypingAttrs[NSParagraphStyleAttributeName] = pStyle; } @@ -174,7 +186,10 @@ - (void)removeTyping { } else { NSMutableParagraphStyle *pStyle = [newTypingAttrs[NSParagraphStyleAttributeName] mutableCopy]; - pStyle.textLists = @[]; + pStyle.textLists = pStyle.textLists = + [TextListUtils textListsByRemoving:[self getValue] + withPrefix:[self getMarkerPrefix] + fromArray:pStyle.textLists]; newTypingAttrs[NSParagraphStyleAttributeName] = pStyle; } @@ -190,8 +205,8 @@ - (BOOL)styleCondition:(id)value range:(NSRange)range { [valueString isEqualToString:[self getValue]]; } else { NSParagraphStyle *pStyle = (NSParagraphStyle *)value; - return pStyle != nullptr && [pStyle.textLists.firstObject.markerFormat - isEqualToString:[self getValue]]; + return pStyle != nullptr && [TextListUtils textLists:pStyle.textLists + containsValue:[self getValue]]; } } diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index e28769369..568b8e55a 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -90,6 +90,14 @@ - (BOOL)handleNewlinesInRange:(NSRange)range replacementText:(NSString *)text; @end +@interface AlignmentStyle : StyleBase +- (void)addAlignment:(NSTextAlignment)alignment + range:(NSRange)range + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange; +- (NSString *)getStyleState; +@end + @interface BlockQuoteStyle : StyleBase @end diff --git a/ios/interfaces/StyleTypeEnum.h b/ios/interfaces/StyleTypeEnum.h index 629f1f040..701d79eaf 100644 --- a/ios/interfaces/StyleTypeEnum.h +++ b/ios/interfaces/StyleTypeEnum.h @@ -8,6 +8,7 @@ typedef NS_ENUM(NSInteger, StyleType) { UnorderedList, OrderedList, CheckboxList, + Alignment, H1, H2, H3, diff --git a/ios/styles/AlignmentStyle.mm b/ios/styles/AlignmentStyle.mm new file mode 100644 index 000000000..afd9dde03 --- /dev/null +++ b/ios/styles/AlignmentStyle.mm @@ -0,0 +1,203 @@ +#import "AlignmentUtils.h" +#import "StyleHeaders.h" +#import "TextListUtils.h" + +@implementation AlignmentStyle + ++ (StyleType)getType { + return Alignment; +} + +- (NSString *)getValue { + return @"EnrichedAlignmentNatural"; +} + +- (NSString *)getMarkerPrefix { + return @"EnrichedAlignment"; +} + +- (BOOL)isParagraph { + return YES; +} + +- (BOOL)appliesStylingToTyping { + return YES; +} + +- (void)toggle:(NSRange)range { + // no-op for alignments +} + +- (void)applyStyling:(NSRange)range { + [self.host.textView.textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:range + options:0 + usingBlock:^(id _Nullable value, NSRange subRange, + BOOL *_Nonnull stop) { + NSMutableParagraphStyle *pStyle = + [(NSParagraphStyle *)value mutableCopy]; + + NSString *marker = + [TextListUtils + firstTextListWithPrefix:[self getMarkerPrefix] + inArray:pStyle.textLists] + .markerFormat; + NSTextAlignment alignment = + [AlignmentUtils markerToAlignment:marker]; + pStyle.alignment = alignment; + [self.host.textView.textStorage + addAttribute:NSParagraphStyleAttributeName + value:pStyle + range:subRange]; + }]; +} + +- (NSRange)actualUsedRange:(NSRange)range { + NSRange paragraphRange = + [self.host.textView.textStorage.string paragraphRangeForRange:range]; + return [self expandRangeToContiguousList:paragraphRange]; +} + +- (void)addAlignment:(NSTextAlignment)alignment + range:(NSRange)range + withTyping:(BOOL)withTyping + withDirtyRange:(BOOL)withDirtyRange { + NSString *value = [AlignmentUtils alignmentToMarker:alignment]; + + [self add:range + withValue:value + withTyping:withTyping + withDirtyRange:withDirtyRange]; +} + +- (BOOL)styleCondition:(id)value range:(NSRange)range { + NSParagraphStyle *pStyle = (NSParagraphStyle *)value; + if (pStyle == nil) + return NO; + return [TextListUtils textLists:pStyle.textLists + containsPrefix:[self getMarkerPrefix]]; +} + +- (void)reapplyFromStylePair:(StylePair *)pair { + NSRange range = [pair.rangeValue rangeValue]; + NSParagraphStyle *savedPStyle = pair.styleValue; + NSString *markerFormat = + [TextListUtils firstTextListWithPrefix:[self getMarkerPrefix] + inArray:savedPStyle.textLists] + .markerFormat; + if (markerFormat == nil) + return; + + [self add:range withValue:markerFormat withTyping:NO withDirtyRange:NO]; +} + +- (NSString *)getStyleState { + UITextView *textView = self.host.textView; + NSParagraphStyle *paraStyle = + textView.typingAttributes[NSParagraphStyleAttributeName]; + + NSString *marker = + [TextListUtils firstTextListWithPrefix:[self getMarkerPrefix] + inArray:paraStyle.textLists] + .markerFormat; + + NSTextAlignment currentAlignment = [AlignmentUtils markerToAlignment:marker]; + return [AlignmentUtils alignmentToString:currentAlignment]; +} + +- (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes { + NSMutableParagraphStyle *pStyle = + [attributes[NSParagraphStyleAttributeName] mutableCopy]; + if (pStyle == nil) + return; + NSString *marker = + [TextListUtils firstTextListWithPrefix:[self getMarkerPrefix] + inArray:pStyle.textLists] + .markerFormat; + NSTextAlignment alignment = [AlignmentUtils markerToAlignment:marker]; + pStyle.alignment = alignment; + attributes[NSParagraphStyleAttributeName] = pStyle; +} + +- (NSRange)expandRangeToContiguousList:(NSRange)range { + NSString *text = self.host.textView.textStorage.string; + if (text.length == 0) + return range; + + NSArray *listStyles = @[ + self.host.stylesDict[@([UnorderedListStyle getType])], + self.host.stylesDict[@([OrderedListStyle getType])], + self.host.stylesDict[@([CheckboxListStyle getType])] + ]; + + NSRange expandedRange = range; + + // Expand Backward + NSRange startParagraph = + [text paragraphRangeForRange:NSMakeRange(range.location, 0)]; + + // Find which list style is active at the start + StyleBase *activeStartStyle = nil; + for (StyleBase *style in listStyles) { + if ([style detect: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 detect: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 + StyleBase *activeEndStyle = nil; + for (StyleBase *style in listStyles) { + if ([style detect: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 detect: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/styles/CheckboxListStyle.mm b/ios/styles/CheckboxListStyle.mm index b7e01ac6a..ee1469c16 100644 --- a/ios/styles/CheckboxListStyle.mm +++ b/ios/styles/CheckboxListStyle.mm @@ -2,6 +2,7 @@ #import "RangeUtils.h" #import "StyleHeaders.h" #import "TextInsertionUtils.h" +#import "TextListUtils.h" @implementation CheckboxListStyle @@ -13,6 +14,10 @@ - (NSString *)getValue { return @"EnrichedCheckbox0"; } +- (NSString *)getMarkerPrefix { + return @"EnrichedCheckbox"; +} + - (BOOL)isParagraph { return YES; } @@ -45,9 +50,10 @@ - (void)applyStyling:(NSRange)range { - (BOOL)styleCondition:(id)value range:(NSRange)range { NSParagraphStyle *pStyle = (NSParagraphStyle *)value; - return pStyle != nullptr && pStyle.textLists.count == 1 && - [pStyle.textLists.firstObject.markerFormat - hasPrefix:@"EnrichedCheckbox"]; + if (pStyle == nil) + return NO; + return [TextListUtils textLists:pStyle.textLists + containsPrefix:[self getMarkerPrefix]]; } - (void)toggleWithChecked:(BOOL)checked range:(NSRange)range { @@ -81,9 +87,8 @@ - (void)addWithChecked:(BOOL)checked - (void)reapplyFromStylePair:(StylePair *)pair { NSRange range = [pair.rangeValue rangeValue]; NSParagraphStyle *savedPStyle = (NSParagraphStyle *)pair.styleValue; - BOOL checked = - savedPStyle != nullptr && [savedPStyle.textLists.firstObject.markerFormat - isEqualToString:@"EnrichedCheckbox1"]; + BOOL checked = [TextListUtils textLists:savedPStyle.textLists + containsValue:@"EnrichedCheckbox1"]; [self addWithChecked:checked range:range withTyping:NO withDirtyRange:NO]; } @@ -97,8 +102,9 @@ - (void)toggleCheckedAt:(NSUInteger)location [self.host.textView.textStorage attribute:NSParagraphStyleAttributeName atIndex:location effectiveRange:NULL]; - NSTextList *list = pStyle.textLists.firstObject; - + NSTextList *list = + [TextListUtils firstTextListWithPrefix:[self getMarkerPrefix] + inArray:pStyle.textLists]; BOOL isCurrentlyChecked = [list.markerFormat isEqualToString:@"EnrichedCheckbox1"]; @@ -121,14 +127,8 @@ - (BOOL)getCheckboxStateAt:(NSUInteger)location { atIndex:location effectiveRange:NULL]; - if (style && style.textLists.count > 0) { - NSTextList *list = style.textLists.firstObject; - if ([list.markerFormat isEqualToString:@"EnrichedCheckbox1"]) { - return YES; - } - } - - return NO; + return [TextListUtils textLists:style.textLists + containsValue:@"EnrichedCheckbox1"]; } - (BOOL)handleNewlinesInRange:(NSRange)range replacementText:(NSString *)text { diff --git a/ios/styles/EnrichedTextStyles.mm b/ios/styles/EnrichedTextStyles.mm index d4b6cbfbf..fde9abfac 100644 --- a/ios/styles/EnrichedTextStyles.mm +++ b/ios/styles/EnrichedTextStyles.mm @@ -54,5 +54,8 @@ @implementation EnrichedTextMentionStyle @implementation EnrichedTextCheckboxListStyle @end +@implementation EnrichedTextAlignmentStyle +@end + @implementation EnrichedTextImageStyle @end diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm index 277e28c98..1f930a015 100644 --- a/ios/textHtmlParser/TextHtmlParser.mm +++ b/ios/textHtmlParser/TextHtmlParser.mm @@ -1,4 +1,5 @@ #import "TextHtmlParser.h" +#import "AlignmentEntry.h" #import "EnrichedTextView.h" #import "HtmlParser.h" #import "LinkData.h" @@ -33,12 +34,14 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { NSArray *result = [HtmlParser getTextAndStylesFromHtml:normalized]; NSString *plainText = result[0]; NSArray *processedStyles = result[1]; + NSArray *alignments = result[2]; NSMutableAttributedString *body = [[NSMutableAttributedString alloc] initWithString:plainText attributes:_view->defaultTypingAttributes]; [_view->textView.textStorage setAttributedString:body]; [self applyProcessedStyles:processedStyles]; + [self applyProcessedAlignments:alignments]; } @catch (NSException *exception) { RCTLogWarn(@"[EnrichedTextView]: Failed to parse HTML: (%@), falling back " @"to raw input.", @@ -157,4 +160,22 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles { } } +- (void)applyProcessedAlignments:(NSArray *)alignments { + AlignmentStyle *alignmentStyle = + _view.stylesDict[@([AlignmentStyle getType])]; + + if (alignmentStyle == nil) { + return; + } + + for (AlignmentEntry *entry in alignments) { + NSRange finalRange = NSMakeRange(entry.range.location, entry.range.length); + [alignmentStyle addAlignment:entry.alignment + range:finalRange + withTyping:NO + withDirtyRange:NO]; + [alignmentStyle applyStyling:finalRange]; + } +} + @end diff --git a/ios/utils/AlignmentUtils.h b/ios/utils/AlignmentUtils.h new file mode 100644 index 000000000..eee565e00 --- /dev/null +++ b/ios/utils/AlignmentUtils.h @@ -0,0 +1,16 @@ +#import "AlignmentEntry.h" +#import "EnrichedTextInputView.h" +#import "StyleHeaders.h" +#import + +@interface AlignmentUtils : NSObject + ++ (NSString *)alignmentToString:(NSTextAlignment)alignmentl; + ++ (NSTextAlignment)stringToAlignment:(NSString *)alignmentString; + ++ (NSString *)alignmentToMarker:(NSTextAlignment)alignment; + ++ (NSTextAlignment)markerToAlignment:(NSString *)marker; + +@end diff --git a/ios/utils/AlignmentUtils.mm b/ios/utils/AlignmentUtils.mm new file mode 100644 index 000000000..f8155cf23 --- /dev/null +++ b/ios/utils/AlignmentUtils.mm @@ -0,0 +1,69 @@ +#import "AlignmentUtils.h" +#import "RangeUtils.h" +#import "StyleHeaders.h" + +@implementation AlignmentUtils + ++ (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"; + } +} + ++ (NSTextAlignment)stringToAlignment:(NSString *)alignmentString { + NSString *normalized = [alignmentString lowercaseString]; + + if ([normalized isEqualToString:@"left"]) { + return NSTextAlignmentLeft; + } + if ([normalized isEqualToString:@"center"]) { + return NSTextAlignmentCenter; + } + if ([normalized isEqualToString:@"right"]) { + return NSTextAlignmentRight; + } + if ([normalized isEqualToString:@"justify"]) { + return NSTextAlignmentJustified; + } + + return NSTextAlignmentNatural; +} + ++ (NSTextAlignment)markerToAlignment:(NSString *)marker { + if ([marker isEqualToString:@"EnrichedAlignmentLeft"]) { + return NSTextAlignmentLeft; + } else if ([marker isEqualToString:@"EnrichedAlignmentCenter"]) { + return NSTextAlignmentCenter; + } else if ([marker isEqualToString:@"EnrichedAlignmentRight"]) { + return NSTextAlignmentRight; + } else if ([marker isEqualToString:@"EnrichedAlignmentJustified"]) { + return NSTextAlignmentJustified; + } + return NSTextAlignmentNatural; +} + ++ (NSString *)alignmentToMarker:(NSTextAlignment)alignment { + if (alignment == NSTextAlignmentLeft) { + return @"EnrichedAlignmentLeft"; + } else if (alignment == NSTextAlignmentCenter) { + return @"EnrichedAlignmentCenter"; + } else if (alignment == NSTextAlignmentRight) { + return @"EnrichedAlignmentRight"; + } else if (alignment == NSTextAlignmentJustified) { + return @"EnrichedAlignmentJustified"; + } + + return @"EnrichedAlignmentNatural"; +} + +@end diff --git a/ios/utils/ParagraphAttributesUtils.mm b/ios/utils/ParagraphAttributesUtils.mm index e9397266f..4a35defb0 100644 --- a/ios/utils/ParagraphAttributesUtils.mm +++ b/ios/utils/ParagraphAttributesUtils.mm @@ -1,4 +1,5 @@ #import "ParagraphAttributesUtils.h" +#import "AlignmentUtils.h" #import "EnrichedTextInputView.h" #import "RangeUtils.h" #import "StyleHeaders.h" @@ -44,6 +45,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 @@ -59,8 +66,8 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr host:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = - typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput + preserveAlignment:savedAlignment]; if (style == cbLStyle) { [cbLStyle addWithChecked:isCurrentlyChecked @@ -83,7 +90,7 @@ + (BOOL)handleBackspaceInRange:(NSRange)range additionalAttributes:nullptr host:typedInput withSelection:YES]; - typedInput->textView.typingAttributes = typedInput->defaultTypingAttributes; + [self resetTypingAttributes:typedInput preserveAlignment:savedAlignment]; return YES; } @@ -234,19 +241,41 @@ + (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 host: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]; + + NSMutableParagraphStyle *paraStyle = + [resetAttrs[NSParagraphStyleAttributeName] mutableCopy] + ?: [[NSMutableParagraphStyle alloc] init]; + paraStyle.textLists = @[ [[NSTextList alloc] + initWithMarkerFormat:[AlignmentUtils alignmentToMarker:alignment] + options:0] ]; + 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/StyleUtils.mm b/ios/utils/StyleUtils.mm index 5372e8cdc..772841927 100644 --- a/ios/utils/StyleUtils.mm +++ b/ios/utils/StyleUtils.mm @@ -76,6 +76,7 @@ + (NSDictionary *)conflictMap { @([UnorderedListStyle getType]), @([OrderedListStyle getType]), @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]) ], + @([AlignmentStyle getType]) : @[], @([BlockQuoteStyle getType]) : @[ @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), @@ -118,6 +119,7 @@ + (NSDictionary *)blockingMap { @([UnorderedListStyle getType]) : @[], @([OrderedListStyle getType]) : @[], @([CheckboxListStyle getType]) : @[], + @([AlignmentStyle getType]) : @[], @([BlockQuoteStyle getType]) : @[], @([CodeBlockStyle getType]) : @[], @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ] @@ -127,25 +129,38 @@ + (NSDictionary *)blockingMap { + (NSDictionary *)stylesDictForHost:(id)host isInput:(BOOL)isInput { NSArray *baseClasses = @[ - [BoldStyle class], [ItalicStyle class], [UnderlineStyle class], - [StrikethroughStyle class], [InlineCodeStyle class], [LinkStyle class], - [MentionStyle class], [H1Style class], [H2Style class], [H3Style class], - [H4Style class], [H5Style class], [H6Style class], - [UnorderedListStyle class], [OrderedListStyle class], - [CheckboxListStyle class], [BlockQuoteStyle class], [CodeBlockStyle class], - [ImageStyle class] + [BoldStyle class], [ItalicStyle class], + [UnderlineStyle class], [StrikethroughStyle class], + [InlineCodeStyle class], [LinkStyle class], + [MentionStyle class], [H1Style class], + [H2Style class], [H3Style class], + [H4Style class], [H5Style class], + [H6Style class], [UnorderedListStyle class], + [OrderedListStyle class], [CheckboxListStyle class], + [AlignmentStyle class], [BlockQuoteStyle class], + [CodeBlockStyle class], [ImageStyle class] ]; NSArray *viewerClasses = @[ - [EnrichedTextBoldStyle class], [EnrichedTextItalicStyle class], - [EnrichedTextUnderlineStyle class], [EnrichedTextStrikethroughStyle class], - [EnrichedTextInlineCodeStyle class], [EnrichedTextLinkStyle class], - [EnrichedTextMentionStyle class], [EnrichedTextH1Style class], - [EnrichedTextH2Style class], [EnrichedTextH3Style class], - [EnrichedTextH4Style class], [EnrichedTextH5Style class], - [EnrichedTextH6Style class], [EnrichedTextUnorderedListStyle class], - [EnrichedTextOrderedListStyle class], [EnrichedTextCheckboxListStyle class], - [EnrichedTextBlockQuoteStyle class], [EnrichedTextCodeBlockStyle class], + [EnrichedTextBoldStyle class], + [EnrichedTextItalicStyle class], + [EnrichedTextUnderlineStyle class], + [EnrichedTextStrikethroughStyle class], + [EnrichedTextInlineCodeStyle class], + [EnrichedTextLinkStyle class], + [EnrichedTextMentionStyle class], + [EnrichedTextH1Style class], + [EnrichedTextH2Style class], + [EnrichedTextH3Style class], + [EnrichedTextH4Style class], + [EnrichedTextH5Style class], + [EnrichedTextH6Style class], + [EnrichedTextUnorderedListStyle class], + [EnrichedTextOrderedListStyle class], + [EnrichedTextCheckboxListStyle class], + [EnrichedTextAlignmentStyle class], + [EnrichedTextBlockQuoteStyle class], + [EnrichedTextCodeBlockStyle class], [EnrichedTextImageStyle class] ]; diff --git a/ios/utils/TextListUtils.h b/ios/utils/TextListUtils.h new file mode 100644 index 000000000..ebf44ab13 --- /dev/null +++ b/ios/utils/TextListUtils.h @@ -0,0 +1,40 @@ +#pragma once +#import + +@interface TextListUtils : NSObject + +// Appends value to the array. If exclusivePrefix is non-nil, any existing +// entry whose markerFormat starts with that prefix is evicted first, ensuring +// only one value from the family is present at a time. ++ (NSArray *_Nonnull) + textListsByAdding:(NSString *_Nonnull)value + withExclusivePrefix:(NSString *_Nullable)prefix + toArray:(NSArray *_Nullable)existing; + +// Returns a new array with every entry whose markerFormat equals value removed +// or whose markerFormat starts with prefix ++ (NSArray *_Nonnull) + textListsByRemoving:(NSString *_Nonnull)value + withPrefix:(NSString *_Nullable)prefix + fromArray:(NSArray *_Nullable)existing; + +// Returns a new array with every entry whose markerFormat starts with prefix +// removed. ++ (NSArray *_Nonnull) + textListsByRemovingPrefix:(NSString *_Nullable)prefix + fromArray:(NSArray *_Nullable)existing; + +// Returns YES if any entry's markerFormat equals value exactly. ++ (BOOL)textLists:(NSArray *_Nullable)textLists + containsValue:(NSString *_Nonnull)value; + +// Returns YES if any entry's markerFormat starts with prefix. ++ (BOOL)textLists:(NSArray *_Nullable)textLists + containsPrefix:(NSString *_Nullable)prefix; + +// Returns the first entry whose markerFormat starts with prefix, or nil. ++ (NSTextList *_Nullable) + firstTextListWithPrefix:(NSString *_Nullable)prefix + inArray:(NSArray *_Nullable)textLists; + +@end diff --git a/ios/utils/TextListUtils.mm b/ios/utils/TextListUtils.mm new file mode 100644 index 000000000..0d46c148f --- /dev/null +++ b/ios/utils/TextListUtils.mm @@ -0,0 +1,93 @@ +#import "TextListUtils.h" + +@implementation TextListUtils + ++ (NSArray *_Nonnull) + textListsByAdding:(NSString *_Nonnull)value + withExclusivePrefix:(NSString *_Nullable)prefix + toArray:(NSArray *_Nullable)existing { + NSMutableArray *updated = + existing ? [existing mutableCopy] : [NSMutableArray array]; + + if (prefix != nil) { + NSUInteger i = 0; + while (i < updated.count) { + if ([updated[i].markerFormat hasPrefix:prefix]) { + if ([updated[i].markerFormat isEqualToString:value]) { + return updated; + } + [updated removeObjectAtIndex:i]; + } else { + i++; + } + } + } else { + for (NSTextList *list in updated) { + if ([list.markerFormat isEqualToString:value]) { + return updated; + } + } + } + + [updated addObject:[[NSTextList alloc] initWithMarkerFormat:value options:0]]; + return updated; +} + ++ (NSArray *_Nonnull) + textListsByRemoving:(NSString *_Nonnull)value + withPrefix:(NSString *_Nullable)prefix + fromArray:(NSArray *_Nullable)existing { + NSMutableArray *updated = [NSMutableArray array]; + for (NSTextList *list in existing) { + if ((prefix == nullptr && ![list.markerFormat isEqualToString:value]) || + (prefix != nullptr && ![list.markerFormat hasPrefix:prefix])) { + [updated addObject:list]; + } + } + return updated; +} + ++ (NSArray *_Nonnull) + textListsByRemovingPrefix:(NSString *_Nullable)prefix + fromArray:(NSArray *_Nullable)existing { + NSMutableArray *updated = [NSMutableArray array]; + for (NSTextList *list in existing) { + if (![list.markerFormat hasPrefix:prefix]) { + [updated addObject:list]; + } + } + return updated; +} + ++ (BOOL)textLists:(NSArray *_Nullable)textLists + containsValue:(NSString *_Nonnull)value { + for (NSTextList *list in textLists) { + if ([list.markerFormat isEqualToString:value]) { + return YES; + } + } + return NO; +} + ++ (BOOL)textLists:(NSArray *_Nullable)textLists + containsPrefix:(NSString *_Nullable)prefix { + for (NSTextList *list in textLists) { + if ([list.markerFormat hasPrefix:prefix]) { + return YES; + } + } + return NO; +} + ++ (NSTextList *_Nullable) + firstTextListWithPrefix:(NSString *_Nullable)prefix + inArray:(NSArray *_Nullable)textLists { + for (NSTextList *list in textLists) { + if ([list.markerFormat hasPrefix:prefix]) { + return list; + } + } + return nil; +} + +@end diff --git a/ios/utils/ZeroWidthSpaceUtils.h b/ios/utils/ZeroWidthSpaceUtils.h index deb02df28..0b22baed2 100644 --- a/ios/utils/ZeroWidthSpaceUtils.h +++ b/ios/utils/ZeroWidthSpaceUtils.h @@ -6,6 +6,8 @@ + (void)handleZeroWidthSpacesInHost:(id)host; + (void)addSpacesIfNeededInHost:(id)host inRange:(NSRange)range; ++ (void)applyKernForZeroWidthSpacesInRange:(NSRange)range + host:(id)host; + (BOOL)handleBackspaceInRange:(NSRange)range replacementText:(NSString *)text host:(id)host; diff --git a/ios/utils/ZeroWidthSpaceUtils.mm b/ios/utils/ZeroWidthSpaceUtils.mm index 715a5cb5b..4bc2e2580 100644 --- a/ios/utils/ZeroWidthSpaceUtils.mm +++ b/ios/utils/ZeroWidthSpaceUtils.mm @@ -277,4 +277,34 @@ + (BOOL)removeZWSStyleInRange:(NSRange)range host:(id)host { return NO; } ++ (void)applyKernForZeroWidthSpacesInRange:(NSRange)range + host:(id)host { + if (host == nullptr || + range.location + range.length > host.textView.textStorage.length) { + return; + } + + NSString *text = [host.textView.textStorage.string substringWithRange:range]; + + // Give \u200B a tiny kern so the layout engine recognizes ZWS-only lines + // under right/center alignment (zero advance width causes height collapse). + [text + enumerateSubstringsInRange:NSMakeRange(0, text.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString *substring, NSRange substringRange, + NSRange enclosingRange, BOOL *stop) { + if (![substring isEqualToString:@"\u200B"]) { + return; + } + + NSRange kernRange = NSMakeRange( + range.location + substringRange.location, + substringRange.length); + [host.textView.textStorage + addAttribute:NSKernAttributeName + value:@(__FLT_EPSILON__) + range:kernRange]; + }]; +} + @end 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..b344aff64 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -123,6 +123,7 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; } export interface OnLinkDetected { @@ -273,6 +274,7 @@ export interface OnContextMenuItemPressEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; }; } @@ -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 c3917675f..b5e5ff818 100644 --- a/src/types.ts +++ b/src/types.ts @@ -347,6 +347,7 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + alignment: string; } export interface OnLinkDetected { @@ -424,6 +425,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 44e0ad3d4..8b3ececf0 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -225,6 +225,7 @@ export const EnrichedTextInput = ({ measureInWindow: () => {}, measureLayout: () => {}, setNativeProps: () => {}, + setTextAlignment: () => {}, }), [editor] ); diff --git a/src/web/useOnChangeState.ts b/src/web/useOnChangeState.ts index 227b46aea..ae02fe139 100644 --- a/src/web/useOnChangeState.ts +++ b/src/web/useOnChangeState.ts @@ -94,6 +94,7 @@ function buildState( isConflicting: editor.isActive('link'), isBlocking: isFormatBlocked('image', editor, htmlStyle), }, + alignment: 'left', }; }