diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt index 7ca6a3adf..a8604022a 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt @@ -227,9 +227,7 @@ class EnrichedMarkdownTextInputManager : view: EnrichedMarkdownTextInputView?, value: Float, ) { - if (value > 0 && view != null) { - view.setLineSpacing(value - view.textSize, 1f) - } + view?.setLineHeightFromProps(value) } @ReactProp(name = "fontFamily") diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt index ffeb7a1d1..fcf8e942e 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt @@ -127,6 +127,9 @@ class EnrichedMarkdownTextInputView( private var headingOverrideBaseSizePx: Float? = null private var baseHintColor: Int? = null + private var lineHeightDip: Float = 0f + private var baseFontSizePx: Float = ceil(PixelUtil.toPixelFromSP(16f)) + // Number of ZWSP empty-list anchors believed live in the buffer. Bumped on insert, // made exact on every strip scan (which recounts what it keeps), and reset when the // buffer is replaced. Incoming markdown is scrubbed of U+200B at parse time, so in @@ -950,10 +953,26 @@ class EnrichedMarkdownTextInputView( fun setFontSizeFromProps(size: Float) { if (size <= 0f) return val sizePx = ceil(PixelUtil.toPixelFromSP(size)) + baseFontSizePx = sizePx setTextSize(TypedValue.COMPLEX_UNIT_PX, sizePx) + applyLineHeight() + layoutManager.invalidateLayout() + } + + fun setLineHeightFromProps(value: Float) { + lineHeightDip = value + applyLineHeight() layoutManager.invalidateLayout() } + private fun applyLineHeight() { + if (lineHeightDip > 0f) { + setLineSpacing(PixelUtil.toPixelFromDIP(lineHeightDip) - baseFontSizePx, 1f) + } else { + setLineSpacing(0f, 1f) + } + } + fun setColorFromProps(colorInt: Int?) { setTextColor(colorInt ?: Color.BLACK) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt index 4e96cb736..cf039316a 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt @@ -14,7 +14,8 @@ class InputLayoutManager( val text = view.text val paint = view.paint - val needUpdate = InputMeasurementStore.store(view.id, text, paint) + val needUpdate = + InputMeasurementStore.store(view.id, text, paint, view.lineSpacingExtra, view.lineSpacingMultiplier) if (!needUpdate) return val state = Arguments.createMap() diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt index d7fc9ac55..ecf804b02 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt @@ -23,6 +23,8 @@ object InputMeasurementStore { val cachedSize: Long, val text: CharSequence?, val paintParams: PaintParams, + val spacingExtra: Float, + val spacingMult: Float, ) private val data = ConcurrentHashMap() @@ -31,14 +33,16 @@ object InputMeasurementStore { id: Int, text: CharSequence?, paint: TextPaint, + spacingExtra: Float, + spacingMult: Float, ): Boolean { val cachedWidth = data[id]?.cachedWidth ?: 0f val cachedSize = data[id]?.cachedSize ?: 0L - val size = measure(cachedWidth, text, paint) + val size = measure(cachedWidth, text, paint, spacingExtra, spacingMult) val paintParams = PaintParams(paint.typeface, paint.textSize) - data[id] = MeasurementParams(cachedWidth, size, text, paintParams) + data[id] = MeasurementParams(cachedWidth, size, text, paintParams, spacingExtra, spacingMult) return size != cachedSize } @@ -84,8 +88,8 @@ object InputMeasurementStore { textSize = value.paintParams.fontSize } - val size = measure(width, value.text, paint) - data[id] = MeasurementParams(width, size, value.text, value.paintParams) + val size = measure(width, value.text, paint, value.spacingExtra, value.spacingMult) + data[id] = MeasurementParams(width, size, value.text, value.paintParams, value.spacingExtra, value.spacingMult) return size } @@ -110,13 +114,18 @@ object InputMeasurementStore { isAntiAlias = true } - return measure(width, text, paint) + val lineHeight = props?.getDouble("lineHeight")?.toFloat() ?: 0f + val spacingExtra = if (lineHeight > 0f) PixelUtil.toPixelFromDIP(lineHeight) - spSize else 0f + + return measure(width, text, paint, spacingExtra, 1f) } private fun measure( maxWidth: Float, text: CharSequence?, paint: TextPaint, + spacingExtra: Float, + spacingMult: Float, ): Long { val content = text ?: "" val widthPx = maxWidth.toInt().coerceAtLeast(0) @@ -125,7 +134,7 @@ object InputMeasurementStore { StaticLayout.Builder .obtain(content, 0, content.length, paint, widthPx) .setIncludePad(true) - .setLineSpacing(0f, 1f) + .setLineSpacing(spacingExtra, spacingMult) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { builder.setBreakStrategy(android.graphics.text.LineBreaker.BREAK_STRATEGY_HIGH_QUALITY) diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h index f7166a42a..689a70100 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h @@ -43,6 +43,15 @@ NS_ASSUME_NONNULL_BEGIN style:(ENRMInputFormatterStyle *)style scopedToRange:(NSRange)scope; +/// Injects the editor-wide `style.baseLineHeight` as each paragraph's +/// minimumLineHeight across `scope`, preserving whatever paragraph styling the +/// inline and block passes already applied (list indent, spacing). Runs last so +/// it covers plain paragraphs (which get no paragraph style otherwise) as well as +/// blocks. A zero baseLineHeight clears any previously-set minimum. +- (void)applyBaseLineHeightToTextView:(ENRMPlatformTextView *)textView + style:(ENRMInputFormatterStyle *)style + scopedToRange:(NSRange)scope; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm index 851d99024..7cc7f5995 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm @@ -286,6 +286,82 @@ - (void)applyBlockRanges:(NSArray *)blockRanges ENRMSetNeedsDisplay(textView); } +- (void)applyBaseLineHeightToTextView:(ENRMPlatformTextView *)textView + style:(ENRMInputFormatterStyle *)style + scopedToRange:(NSRange)scope +{ + NSTextStorage *textStorage = textView.textStorage; + NSUInteger textLength = textStorage.length; + if (textLength == 0) { + return; + } + + NSUInteger scopeStart = MIN(scope.location, textLength); + NSUInteger scopeEnd = MIN(NSMaxRange(scope), textLength); + if (scopeEnd <= scopeStart) { + return; + } + NSRange scopeRange = NSMakeRange(scopeStart, scopeEnd - scopeStart); + + CGFloat lineHeight = style.baseLineHeight > 0 ? style.baseLineHeight : 0; + CGFloat baseFontSize = style.baseFont.pointSize; + + // Collect target paragraph styles first, then write them in one pass — mutating + // NSParagraphStyleAttributeName while enumerating the same attribute is unsafe. + NSMutableArray *targetRanges = [NSMutableArray array]; + NSMutableArray *targetStyles = [NSMutableArray array]; + + [textStorage enumerateAttribute:NSParagraphStyleAttributeName + inRange:scopeRange + options:0 + usingBlock:^(NSParagraphStyle *existing, NSRange range, BOOL *stop) { + // Nothing to set and nothing to clear on an unstyled run. + if (existing == nil && lineHeight <= 0) { + return; + } + + // Clamp (maximumLineHeight) only paragraphs whose glyphs fit the + // requested height. Larger fonts (headings) and attachments keep an + // unbounded max so a small lineHeight never clips them, mirroring + // Android where small values compress body text but spare tall lines. + __block BOOL allowClamp = YES; + if (lineHeight > 0) { + [textStorage enumerateAttributesInRange:range + options:0 + usingBlock:^(NSDictionary *attrs, + NSRange r, BOOL *innerStop) { + UIFont *font = attrs[NSFontAttributeName]; + if ((font && font.pointSize > baseFontSize + 0.5) || + attrs[NSAttachmentAttributeName] != nil) { + allowClamp = NO; + *innerStop = YES; + } + }]; + } + + NSMutableParagraphStyle *paragraphStyle = + existing ? [existing mutableCopy] : [[NSMutableParagraphStyle alloc] init]; + [style applyLineHeightToParagraphStyle:paragraphStyle allowClamp:allowClamp]; + + if (existing != nil && existing.minimumLineHeight == paragraphStyle.minimumLineHeight && + existing.maximumLineHeight == paragraphStyle.maximumLineHeight) { + return; + } + [targetRanges addObject:[NSValue valueWithRange:range]]; + [targetStyles addObject:paragraphStyle]; + }]; + + if (targetRanges.count == 0) { + return; + } + + [textStorage beginEditing]; + for (NSUInteger i = 0; i < targetRanges.count; i++) { + [textStorage addAttribute:NSParagraphStyleAttributeName value:targetStyles[i] range:targetRanges[i].rangeValue]; + } + [textStorage endEditing]; +} + /// Applies `blockFont` over `range` while preserving the symbolic traits already /// present on each existing font run (set by the inline formatting pass). The /// resulting font takes its size and descriptor from `blockFont` but unions in diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatterStyle.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatterStyle.h index c09a3cbcc..d8d88a0e8 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatterStyle.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatterStyle.h @@ -21,6 +21,19 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong) UIFont *baseFont; @property (nonatomic, strong) RCTUIColor *baseTextColor; +/// Editor-wide line height (points) applied to every paragraph, mirroring +/// Android's view-level lineHeight. Set from the base `style.lineHeight` prop; 0 +/// disables it (natural line height). +@property (nonatomic, assign) CGFloat baseLineHeight; + +/// Writes baseLineHeight onto `paragraphStyle`. `minimumLineHeight` is always set +/// so short lines grow to the requested height. `maximumLineHeight` is set too +/// only when `allowClamp` is YES, letting a small lineHeight compress body text +/// below its natural height (matching Android's additive spacing). Callers pass +/// allowClamp = NO for lines that must not be clipped — headings and other +/// oversized/attachment lines. A zero baseLineHeight clears both. +- (void)applyLineHeightToParagraphStyle:(NSMutableParagraphStyle *)paragraphStyle allowClamp:(BOOL)allowClamp; + /// Bold — color override (nil = inherit baseTextColor) @property (nonatomic, strong, nullable) RCTUIColor *boldColor; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatterStyle.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatterStyle.mm index fb3c73340..67535e545 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatterStyle.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatterStyle.mm @@ -34,6 +34,7 @@ - (id)copyWithZone:(NSZone *)zone ENRMInputFormatterStyle *copy = [[ENRMInputFormatterStyle allocWithZone:zone] init]; copy.baseFont = _baseFont; copy.baseTextColor = _baseTextColor; + copy.baseLineHeight = _baseLineHeight; copy.boldColor = _boldColor; copy.italicColor = _italicColor; copy.linkColor = _linkColor; @@ -51,6 +52,13 @@ - (id)copyWithZone:(NSZone *)zone return copy; } +- (void)applyLineHeightToParagraphStyle:(NSMutableParagraphStyle *)paragraphStyle allowClamp:(BOOL)allowClamp +{ + CGFloat lineHeight = _baseLineHeight > 0 ? _baseLineHeight : 0; + paragraphStyle.minimumLineHeight = lineHeight; + paragraphStyle.maximumLineHeight = (lineHeight > 0 && allowClamp) ? lineHeight : 0; +} + - (BOOL)isValidHeadingLevel:(NSInteger)level { return level >= 1 && level <= 6; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputTypingAttributesController.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputTypingAttributesController.mm index 8acb7c09a..70345b55e 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputTypingAttributesController.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputTypingAttributesController.mm @@ -112,6 +112,7 @@ - (void)syncWithCursorBlock RCTUIColor *headingColor = headingLevel >= 1 ? [_formatterStyle headingColorForLevel:headingLevel] : nil; attrs[NSForegroundColorAttributeName] = headingColor ?: _formatterStyle.baseTextColor; + CGFloat baseLineHeight = _formatterStyle.baseLineHeight; ENRMBlockRange *typingListBlock = [_dataSource listBlockForCursorParagraph]; if (typingListBlock != nil) { NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; @@ -119,6 +120,14 @@ - (void)syncWithCursorBlock paragraph.firstLineHeadIndent = indent; paragraph.headIndent = indent; paragraph.paragraphSpacingBefore = _formatterStyle.listItemSpacing; + if (baseLineHeight > 0) { + [_formatterStyle applyLineHeightToParagraphStyle:paragraph allowClamp:YES]; + } + attrs[NSParagraphStyleAttributeName] = paragraph; + } else if (baseLineHeight > 0) { + NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; + // A caret inside a heading must not clamp — the larger glyphs would clip. + [_formatterStyle applyLineHeightToParagraphStyle:paragraph allowClamp:(headingLevel < 1)]; attrs[NSParagraphStyleAttributeName] = paragraph; } else { [attrs removeObjectForKey:NSParagraphStyleAttributeName]; @@ -159,6 +168,17 @@ - (void)resetForSelectionChange - (void)clearListParagraphStyle { + // Leaving a list drops the indent/spacing paragraph style, but a configured + // editor-wide line height must survive, so fall back to a base paragraph style + // carrying only the line height rather than removing the attribute outright. + if (_formatterStyle.baseLineHeight > 0) { + NSMutableDictionary *attrs = [_textView.typingAttributes mutableCopy]; + NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; + [_formatterStyle applyLineHeightToParagraphStyle:paragraph allowClamp:YES]; + attrs[NSParagraphStyleAttributeName] = paragraph; + _textView.typingAttributes = attrs; + return; + } if (_textView.typingAttributes[NSParagraphStyleAttributeName] == nil) { return; } diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index 9a9987e0d..bbdfc8fb1 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -516,7 +516,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [self resetBaseTypingAttributes]; - if (_formattingStore.allRanges.count > 0) { + // Re-apply base typography (font, color, line height) to all existing text, + // not just text that carries inline markdown ranges — a plain paragraph still + // needs the new fontSize/lineHeight. + if (_textView.textStorage.length > 0) { [self applyFormatting]; } @@ -789,10 +792,16 @@ - (void)pasteMarkdown:(NSString *)markdown - (void)resetBaseTypingAttributes { - ENRMSetDefaultTypingAttributes(_textView, @{ + NSMutableDictionary *attributes = [@{ NSFontAttributeName : _formatterStyle.baseFont, NSForegroundColorAttributeName : _formatterStyle.baseTextColor, - }); + } mutableCopy]; + if (_formatterStyle.baseLineHeight > 0) { + NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; + [_formatterStyle applyLineHeightToParagraphStyle:paragraphStyle allowClamp:YES]; + attributes[NSParagraphStyleAttributeName] = paragraphStyle; + } + ENRMSetDefaultTypingAttributes(_textView, attributes); } - (void)applyFormatting @@ -835,6 +844,7 @@ - (void)applyFormattingScopedToRange:(NSRange)scope style:_formatterStyle scopedToRange:scope]; [_formatter applyBlockRanges:_blockStore.allRanges toTextView:_textView style:_formatterStyle scopedToRange:scope]; + [_formatter applyBaseLineHeightToTextView:_textView style:_formatterStyle scopedToRange:scope]; [_detectorPipeline refreshAllStyling]; [self applyWritingDirection]; diff --git a/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h b/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h index 1d28946d1..c02b36012 100644 --- a/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h +++ b/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h @@ -58,6 +58,11 @@ BOOL applyInputStyleProps(ENRMInputFormatterStyle *style, const InputProps &newP changed = YES; } + if (newProps.lineHeight != oldProps.lineHeight) { + style.baseLineHeight = newProps.lineHeight > 0 ? newProps.lineHeight : 0; + changed = YES; + } + if (newProps.fontWeight != oldProps.fontWeight) { CGFloat fontSize = style.baseFont.pointSize; if (!newProps.fontWeight.empty()) {