diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index aabf99e8..3a1eed77 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -14,6 +14,7 @@ import com.swmansion.enriched.markdown.parser.Md4cFlags import com.swmansion.enriched.markdown.parser.Parser import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils import com.swmansion.enriched.markdown.utils.common.FeatureFlags import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer import com.swmansion.enriched.markdown.utils.common.RenderedSegment @@ -185,6 +186,17 @@ class EnrichedMarkdown applySelectionColorsToSegments() } + fun setTextBreakStrategy(strategy: String) { + BreakStrategyUtils.setStrategy(strategy) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + segmentViews.filterIsInstance().forEach { + it.breakStrategy = BreakStrategyUtils.resolveBreakStrategy() + } + } + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true + } + private fun applySelectionColorsToSegments() { segmentViews.filterIsInstance().forEach { it.applySelectionColors(selectionColor, selectionHandleColor) diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt index 457d5e02..87b43e1a 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -148,6 +148,14 @@ class EnrichedMarkdownManager : // No-op on Android — only used on iOS } + @ReactProp(name = "lineBreakStrategyIOS") + override fun setLineBreakStrategyIOS( + view: EnrichedMarkdown?, + strategy: String?, + ) { + // No-op on Android — only used on iOS + } + @ReactProp(name = "streamingAnimation", defaultBoolean = false) override fun setStreamingAnimation( view: EnrichedMarkdown?, @@ -178,6 +186,14 @@ class EnrichedMarkdownManager : view?.spoilerOverlay = SpoilerOverlay.fromString(mode) } + @ReactProp(name = "textBreakStrategy") + override fun setTextBreakStrategy( + view: EnrichedMarkdown?, + strategy: String?, + ) { + view?.setTextBreakStrategy(strategy ?: "highQuality") + } + @ReactProp(name = "contextMenuItems") override fun setContextMenuItems( view: EnrichedMarkdown?, diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt index 5104ff80..e218b43d 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt @@ -19,6 +19,7 @@ import com.swmansion.enriched.markdown.spoiler.SpoilerCapable import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod @@ -290,6 +291,15 @@ class EnrichedMarkdownText applySelectionColors(selectionColor, selectionHandleColor) } + fun setTextBreakStrategy(strategy: String) { + BreakStrategyUtils.setStrategy(strategy) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + breakStrategy = BreakStrategyUtils.resolveBreakStrategy() + } + MeasurementStore.invalidate(id) + scheduleRenderIfNeeded() + } + fun emitOnLinkPress(url: String) { emitLinkPressEvent(url) } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt index 037cb17e..c303b8cd 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt @@ -154,6 +154,14 @@ class EnrichedMarkdownTextManager : // No-op on Android — only used on iOS } + @ReactProp(name = "lineBreakStrategyIOS") + override fun setLineBreakStrategyIOS( + view: EnrichedMarkdownText?, + strategy: String?, + ) { + // No-op on Android — only used on iOS + } + @ReactProp(name = "streamingAnimation", defaultBoolean = false) override fun setStreamingAnimation( view: EnrichedMarkdownText?, @@ -178,6 +186,14 @@ class EnrichedMarkdownTextManager : view?.spoilerOverlay = SpoilerOverlay.fromString(mode) } + @ReactProp(name = "textBreakStrategy") + override fun setTextBreakStrategy( + view: EnrichedMarkdownText?, + strategy: String?, + ) { + view?.setTextBreakStrategy(strategy ?: "highQuality") + } + @ReactProp(name = "contextMenuItems") override fun setContextMenuItems( view: EnrichedMarkdownText?, diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt index a6490d12..fe781ffb 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -2,7 +2,6 @@ package com.swmansion.enriched.markdown import android.content.Context import android.graphics.Typeface -import android.graphics.text.LineBreaker import android.os.Build import android.text.SpannableString import android.text.StaticLayout @@ -19,6 +18,7 @@ import com.swmansion.enriched.markdown.spans.MathMeasureRequest import com.swmansion.enriched.markdown.spans.MathMetrics import com.swmansion.enriched.markdown.spans.MathRenderMode import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils import com.swmansion.enriched.markdown.utils.common.FeatureFlags import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer import com.swmansion.enriched.markdown.utils.common.RenderedSegment @@ -461,8 +461,9 @@ object MeasurementStore { .setIncludePad(false) .setLineSpacing(0f, 1f) .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + @Suppress("WrongConstant") + setBreakStrategy(BreakStrategyUtils.resolveBreakStrategy()) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { setUseLineSpacingFromFallbacks(true) @@ -539,8 +540,9 @@ object MeasurementStore { .setIncludePad(false) .setLineSpacing(0f, 1f) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - builder.setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + @Suppress("WrongConstant") + builder.setBreakStrategy(BreakStrategyUtils.resolveBreakStrategy()) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -576,8 +578,9 @@ object MeasurementStore { .setIncludePad(false) .setLineSpacing(0f, 1f) .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + @Suppress("WrongConstant") + setBreakStrategy(BreakStrategyUtils.resolveBreakStrategy()) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { setUseLineSpacingFromFallbacks(true) diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/common/BreakStrategyUtils.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/BreakStrategyUtils.kt new file mode 100644 index 00000000..108071e4 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/common/BreakStrategyUtils.kt @@ -0,0 +1,39 @@ +package com.swmansion.enriched.markdown.utils.common + +import android.text.Layout + +/** + * Singleton that resolves the break strategy for StaticLayout and TextView. + * + * Both Measurement (via MeasurementStore) and the render (via rendered TextView) + * must use the same value - a mismatch causes the measured line count to differ + * from the rendered line count, which results in the view being sized incorrectly + * and ScrollingMovementMethod (inherited via LinkMovementMethod) silently + * scrolling the overflow. + * + * The strategy is set from the `textBreakStrategy` prop via the view's setter, + * which updates this object before invalidating measurement and triggering a + * re-render. Both MeasurementStore (StaticLayout.Builder) and TextViewSetup + * (TextView.breakStrategy) call resolveBreakStrategy(), so updating it here + * is enough for both paths. + * + * Note: call sites in StaticLayout.Builder suppress "WrongConstant" lint. This is + * intentional - Layout.BREAK_STRATEGY_SIMPLE and LineBreaker.BREAK_STRATEGY_SIMPLE + * are the same integer (0), but the @IntDef annotation on StaticLayout.Builder + * .setBreakStrategy() was changed from Layout.* to LineBreaker.* in API 29. + * The suppression is safe; the Layout.* constants share the same integer values. + */ +object BreakStrategyUtils { + private var strategy: String = "simple" + + fun setStrategy(newStrategy: String?) { + strategy = newStrategy ?: "simple" + } + + fun resolveBreakStrategy(): Int = + when (strategy) { + "highQuality" -> Layout.BREAK_STRATEGY_HIGH_QUALITY + "balanced" -> Layout.BREAK_STRATEGY_BALANCED + else -> Layout.BREAK_STRATEGY_SIMPLE + } +} diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/TextViewSetup.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/TextViewSetup.kt index 1feab30e..de379fbf 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/TextViewSetup.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/TextViewSetup.kt @@ -6,10 +6,14 @@ import android.view.textclassifier.TextClassifier import androidx.appcompat.widget.AppCompatTextView import androidx.core.view.ViewCompat import com.swmansion.enriched.markdown.accessibility.AccessibleMarkdownTextView +import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils fun AccessibleMarkdownTextView.setupAsMarkdownTextView() { setBackgroundColor(Color.TRANSPARENT) includeFontPadding = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + breakStrategy = BreakStrategyUtils.resolveBreakStrategy() + } movementMethod = LinkLongPressMovementMethod.createInstance() setTextIsSelectable(true) customSelectionActionModeCallback = createSelectionActionModeCallback(this) diff --git a/ios/EnrichedMarkdown.mm b/ios/EnrichedMarkdown.mm index 50540a2c..b57e8d17 100644 --- a/ios/EnrichedMarkdown.mm +++ b/ios/EnrichedMarkdown.mm @@ -28,6 +28,7 @@ #import "MarkdownAccessibilityElementBuilder.h" #import "MarkdownExtractor.h" #import "MeasurementCache.h" +#import "ParagraphStyleUtils.h" #import "RenderedMarkdownSegment.h" #import "RuntimeKeys.h" #import "SegmentReconciler.h" @@ -750,8 +751,15 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & } } + BOOL lineBreakStrategyChanged = newViewProps.lineBreakStrategyIOS != oldViewProps.lineBreakStrategyIOS; + if (lineBreakStrategyChanged) { + NSString *strategy = [[NSString alloc] initWithUTF8String:newViewProps.lineBreakStrategyIOS.c_str()]; + ENRMSetLineBreakStrategy(strategy); + _dirtyFlags |= ENRMDirtyForceHeight; + } + if (markdownChanged || stylePropChanged || md4cFlagsChanged || allowTrailingMarginChanged || - streamingAnimationChanged || streamingConfigChanged) { + streamingAnimationChanged || streamingConfigChanged || lineBreakStrategyChanged) { _pendingStyleFingerprint = computeStyleFingerprint(newViewProps.markdownStyle) ^ std::hash{}(newViewProps.allowTrailingMargin); NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()]; diff --git a/ios/EnrichedMarkdownText.mm b/ios/EnrichedMarkdownText.mm index 8acde324..a98eff59 100644 --- a/ios/EnrichedMarkdownText.mm +++ b/ios/EnrichedMarkdownText.mm @@ -507,7 +507,15 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _spoilerManager.spoilerOverlay = ENRMSpoilerOverlayFromString(modeStr); } - if (markdownChanged || stylePropChanged || md4cFlagsChanged || allowTrailingMarginChanged) { + BOOL lineBreakStrategyChanged = newViewProps.lineBreakStrategyIOS != oldViewProps.lineBreakStrategyIOS; + if (lineBreakStrategyChanged) { + NSString *strategy = [[NSString alloc] initWithUTF8String:newViewProps.lineBreakStrategyIOS.c_str()]; + ENRMSetLineBreakStrategy(strategy); + _forceHeightUpdateOnNextRender = YES; + } + + if (markdownChanged || stylePropChanged || md4cFlagsChanged || allowTrailingMarginChanged || + lineBreakStrategyChanged) { _pendingStyleFingerprint = computeStyleFingerprint(newViewProps.markdownStyle) ^ std::hash{}(newViewProps.allowTrailingMargin); NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()]; diff --git a/ios/utils/ParagraphStyleUtils.h b/ios/utils/ParagraphStyleUtils.h index 40570f03..414de671 100644 --- a/ios/utils/ParagraphStyleUtils.h +++ b/ios/utils/ParagraphStyleUtils.h @@ -16,6 +16,7 @@ void applyBlockSpacingAfter(NSMutableAttributedString *output, CGFloat marginBot void applyLineHeight(NSMutableAttributedString *output, NSRange range, CGFloat lineHeight); void applyTextAlignment(NSMutableAttributedString *output, NSRange range, NSTextAlignment textAlign); NSTextAlignment textAlignmentFromString(NSString *textAlign); +void ENRMSetLineBreakStrategy(NSString *strategy); __END_DECLS diff --git a/ios/utils/ParagraphStyleUtils.m b/ios/utils/ParagraphStyleUtils.m index dfedaf4c..7bf593f0 100644 --- a/ios/utils/ParagraphStyleUtils.m +++ b/ios/utils/ParagraphStyleUtils.m @@ -3,6 +3,20 @@ NSAttributedString *kNewlineAttributedString; static NSParagraphStyle *kBlockSpacerTemplate; +static NSLineBreakStrategy gLineBreakStrategy = NSLineBreakStrategyNone; + +void ENRMSetLineBreakStrategy(NSString *strategy) +{ + if ([strategy isEqualToString:@"standard"]) { + gLineBreakStrategy = NSLineBreakStrategyStandard; + } else if ([strategy isEqualToString:@"hangul-word"]) { + gLineBreakStrategy = NSLineBreakStrategyHangulWordPriority; + } else if ([strategy isEqualToString:@"push-out"]) { + gLineBreakStrategy = NSLineBreakStrategyPushOut; + } else { + gLineBreakStrategy = NSLineBreakStrategyNone; + } +} __attribute__((constructor)) static void initParagraphStyleUtils(void) { @@ -25,6 +39,7 @@ NSWritingDirection currentWritingDirection(void) NSParagraphStyle *existing = [output attribute:NSParagraphStyleAttributeName atIndex:index effectiveRange:NULL]; NSMutableParagraphStyle *style = existing ? [existing mutableCopy] : [[NSMutableParagraphStyle alloc] init]; style.baseWritingDirection = currentWritingDirection(); + style.lineBreakStrategy = gLineBreakStrategy; return style; } diff --git a/src/EnrichedMarkdownNativeComponent.ts b/src/EnrichedMarkdownNativeComponent.ts index 7d83fd4f..5584ddee 100644 --- a/src/EnrichedMarkdownNativeComponent.ts +++ b/src/EnrichedMarkdownNativeComponent.ts @@ -388,6 +388,18 @@ export interface NativeProps extends ViewProps { * Receives the item label, the currently selected text, and the selection range. */ onContextMenuItemPress?: CodegenTypes.BubblingEventHandler; + /** + * Sets the text break strategy on Android (API 23+). + * @default 'highQuality' + * @platform android + */ + textBreakStrategy?: CodegenTypes.WithDefault; + /** + * Sets the line break strategy on iOS 14+. + * @default 'none' + * @platform ios + */ + lineBreakStrategyIOS?: CodegenTypes.WithDefault; } export default codegenNativeComponent('EnrichedMarkdown', { diff --git a/src/EnrichedMarkdownTextNativeComponent.ts b/src/EnrichedMarkdownTextNativeComponent.ts index 4cc5d18d..ac25d5cc 100644 --- a/src/EnrichedMarkdownTextNativeComponent.ts +++ b/src/EnrichedMarkdownTextNativeComponent.ts @@ -387,6 +387,18 @@ export interface NativeProps extends ViewProps { * Receives the item label, the currently selected text, and the selection range. */ onContextMenuItemPress?: CodegenTypes.BubblingEventHandler; + /** + * Sets the text break strategy on Android (API 23+). + * @default 'highQuality' + * @platform android + */ + textBreakStrategy?: CodegenTypes.WithDefault; + /** + * Sets the line break strategy on iOS 14+. + * @default 'none' + * @platform ios + */ + lineBreakStrategyIOS?: CodegenTypes.WithDefault; } export default codegenNativeComponent('EnrichedMarkdownText', { diff --git a/src/native/EnrichedMarkdownText.tsx b/src/native/EnrichedMarkdownText.tsx index 8458b418..23af29c2 100644 --- a/src/native/EnrichedMarkdownText.tsx +++ b/src/native/EnrichedMarkdownText.tsx @@ -55,6 +55,8 @@ export const EnrichedMarkdownText = ({ selectionMenuConfig, selectionColor, selectionHandleColor, + textBreakStrategy = 'highQuality', + lineBreakStrategyIOS, ...rest }: EnrichedMarkdownTextProps) => { const normalizedStyleRef = useRef(null); @@ -166,6 +168,8 @@ export const EnrichedMarkdownText = ({ onContextMenuItemPress: handleContextMenuItemPress, selectionColor, selectionHandleColor, + textBreakStrategy, + lineBreakStrategyIOS, ...rest, }; diff --git a/src/types/MarkdownTextProps.ts b/src/types/MarkdownTextProps.ts index 96a8dba3..17a380f1 100644 --- a/src/types/MarkdownTextProps.ts +++ b/src/types/MarkdownTextProps.ts @@ -213,4 +213,26 @@ export interface EnrichedMarkdownTextProps extends Omit { * @platform web */ dir?: 'ltr' | 'rtl' | 'auto'; + /** + * Sets the text break strategy on Android (API 23+). + * - `'simple'`: no hyphenation, minimal line-break work. + * - `'highQuality'` (default): full paragraph optimization with hyphenation. + * - `'balanced'`: balances line lengths, no hyphenation. + * + * Both the measurement pass and the render pass use this value so that + * measured line counts match rendered line counts. + * @default 'highQuality' + * @platform android + */ + textBreakStrategy?: 'simple' | 'highQuality' | 'balanced'; + /** + * Sets the line break strategy on iOS (iOS 14+). + * - `'none'` (default): no additional line break strategy. + * - `'standard'`: standard line breaking rules. + * - `'hangul-word'`: Korean word-boundary breaking. + * - `'push-out'`: pushes text out to avoid orphaned words. + * @default 'none' + * @platform ios + */ + lineBreakStrategyIOS?: 'none' | 'standard' | 'hangul-word' | 'push-out'; }