Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<EnrichedMarkdownInternalText>().forEach {
it.breakStrategy = BreakStrategyUtils.resolveBreakStrategy()
}
}
dirtyFlags += DirtyFlag.FORCE_HEIGHT
renderPending = true
}

private fun applySelectionColorsToSegments() {
segmentViews.filterIsInstance<EnrichedMarkdownInternalText>().forEach {
it.applySelectionColors(selectionColor, selectionHandleColor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down Expand Up @@ -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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand All @@ -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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default/fallback here is still "simple", but managers, codegen, and the JS wrapper all default to "highQuality". Let's use 'highQuality' as default.


Comment thread
eszlamczyk marked this conversation as resolved.
fun setStrategy(newStrategy: String?) {
strategy = newStrategy ?: "simple"
}
Comment thread
eszlamczyk marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BreakStrategyUtils is a process-wide singleton, but textBreakStrategy is a per-view prop - with multiple markdown views on screen, the last setter wins for all measurement/render paths.

RN Text doesn't do this: each shadow node stores its own strategy (mTextBreakStrategy / ParagraphAttributes.textBreakStrategy) and passes the same value to both StaticLayout.Builder (measure) and ReactTextView.setBreakStrategy() (render).

Could we follow the existing MeasurementStore.fontScalingSettings pattern instead - store per viewId, resolve during measurement, set on each view's TextView?


fun resolveBreakStrategy(): Int =
when (strategy) {
"highQuality" -> Layout.BREAK_STRATEGY_HIGH_QUALITY
"balanced" -> Layout.BREAK_STRATEGY_BALANCED
else -> Layout.BREAK_STRATEGY_SIMPLE
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion ios/EnrichedMarkdown.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<bool>{}(newViewProps.allowTrailingMargin);
NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()];
Expand Down
10 changes: 9 additions & 1 deletion ios/EnrichedMarkdownText.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>{}(newViewProps.allowTrailingMargin);
NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()];
Expand Down
1 change: 1 addition & 0 deletions ios/utils/ParagraphStyleUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions ios/utils/ParagraphStyleUtils.m
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@

NSAttributedString *kNewlineAttributedString;
static NSParagraphStyle *kBlockSpacerTemplate;
static NSLineBreakStrategy gLineBreakStrategy = NSLineBreakStrategyNone;

void ENRMSetLineBreakStrategy(NSString *strategy)
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file already has statics like kBlockSpacerTemplate, but those are immutable shared templates (copied per use, same for all views).

gLineBreakStrategy is different — it's mutable state for a per-view prop. If two markdown views use different lineBreakStrategyIOS values, the last setter wins in getOrCreateParagraphStyle.

Font scaling is already stored per view here; RN Text does the same for lineBreakStrategy. Could we store this on the view/renderer and pass it through instead of a global?

if ([strategy isEqualToString:@"standard"]) {
Comment thread
eszlamczyk marked this conversation as resolved.
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)
{
Expand All @@ -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;
Comment thread
eszlamczyk marked this conversation as resolved.
}

Expand Down
12 changes: 12 additions & 0 deletions src/EnrichedMarkdownNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,18 @@ export interface NativeProps extends ViewProps {
* Receives the item label, the currently selected text, and the selection range.
*/
onContextMenuItemPress?: CodegenTypes.BubblingEventHandler<OnContextMenuItemPressEvent>;
/**
* Sets the text break strategy on Android (API 23+).
* @default 'highQuality'
* @platform android
*/
textBreakStrategy?: CodegenTypes.WithDefault<string, 'highQuality'>;
/**
* Sets the line break strategy on iOS 14+.
* @default 'none'
* @platform ios
*/
lineBreakStrategyIOS?: CodegenTypes.WithDefault<string, 'none'>;
}

export default codegenNativeComponent<NativeProps>('EnrichedMarkdown', {
Expand Down
12 changes: 12 additions & 0 deletions src/EnrichedMarkdownTextNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,18 @@ export interface NativeProps extends ViewProps {
* Receives the item label, the currently selected text, and the selection range.
*/
onContextMenuItemPress?: CodegenTypes.BubblingEventHandler<OnContextMenuItemPressEvent>;
/**
* Sets the text break strategy on Android (API 23+).
* @default 'highQuality'
* @platform android
*/
textBreakStrategy?: CodegenTypes.WithDefault<string, 'highQuality'>;
/**
* Sets the line break strategy on iOS 14+.
* @default 'none'
* @platform ios
*/
lineBreakStrategyIOS?: CodegenTypes.WithDefault<string, 'none'>;
}

export default codegenNativeComponent<NativeProps>('EnrichedMarkdownText', {
Expand Down
4 changes: 4 additions & 0 deletions src/native/EnrichedMarkdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export const EnrichedMarkdownText = ({
selectionMenuConfig,
selectionColor,
selectionHandleColor,
textBreakStrategy = 'highQuality',
lineBreakStrategyIOS,
...rest
}: EnrichedMarkdownTextProps) => {
const normalizedStyleRef = useRef<MarkdownStyleInternal | null>(null);
Expand Down Expand Up @@ -166,6 +168,8 @@ export const EnrichedMarkdownText = ({
onContextMenuItemPress: handleContextMenuItemPress,
selectionColor,
selectionHandleColor,
textBreakStrategy,
lineBreakStrategyIOS,
...rest,
};

Expand Down
22 changes: 22 additions & 0 deletions src/types/MarkdownTextProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,26 @@ export interface EnrichedMarkdownTextProps extends Omit<ViewProps, 'style'> {
* @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.
Comment thread
eszlamczyk marked this conversation as resolved.
*
* 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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we update API_RENCE doc with these props? 🙏

}
Loading