Skip to content

Customize line height for headings #535

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 30 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4c16500
Customize line height for headings
tomekzaw Nov 5, 2024
29b8bc9
Fix web tests
tomekzaw Nov 5, 2024
3ccb597
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Nov 6, 2024
30ab8bd
Update Podfile.lock
tomekzaw Nov 6, 2024
5ba3332
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Dec 7, 2024
579a2aa
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Dec 9, 2024
0c0a6dc
Update Podfile.lock
tomekzaw Dec 9, 2024
abf5e6b
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Dec 22, 2024
a08da3b
Update Podfile.lock
tomekzaw Dec 22, 2024
3ced129
Unify `NSMutableParagraphStyle` creation
tomekzaw Dec 22, 2024
6a1a99b
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Jan 10, 2025
4b32ede
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Jan 31, 2025
06b1ef4
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Mar 13, 2025
f0451e0
Fix line height for h1 on iOS when line height is set on text input
tomekzaw Mar 14, 2025
6f9838d
Add patch for react-native
tomekzaw Mar 14, 2025
16862ac
Fix heading line height in blockquote
tomekzaw Mar 14, 2025
7ceeadc
Fix line height of regular lines
tomekzaw Mar 14, 2025
027dbda
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Mar 18, 2025
bae14a5
Fix cursor height in empty text input with line height set
tomekzaw Mar 18, 2025
83a5feb
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Mar 18, 2025
65c1d45
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Mar 19, 2025
1850eb8
WIP
tomekzaw Mar 19, 2025
9495a26
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Mar 23, 2025
6d91254
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Mar 25, 2025
11e6265
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Apr 14, 2025
914b5b9
Fix react-native patch
tomekzaw Apr 14, 2025
d47d3f7
Merge branch 'main' into @tomekzaw/line-height
tomekzaw Apr 24, 2025
f262fbe
Merge branch 'main' into @tomekzaw/line-height
tomekzaw May 14, 2025
121b311
Bump react-native patch version
tomekzaw May 15, 2025
11ef181
Add buttons for toggling text and heading line height
tomekzaw May 15, 2025
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
2 changes: 1 addition & 1 deletion WebExample/__tests__/styles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ test.describe('markdown content styling', () => {
});

test('h1', async ({page}) => {
await testMarkdownContentStyle({testContent: 'header1', style: 'font-size: 25px; font-weight: bold;', page});
await testMarkdownContentStyle({testContent: 'header1', style: 'font-size: 25px; line-height: 50px; font-weight: bold;', page});
});

test('inline code', async ({page}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@ private void applyRange(@NonNull SpannableStringBuilder ssb, @NonNull MarkdownRa
break;
case "h1":
setSpan(ssb, new MarkdownBoldSpan(), start, end);
CustomLineHeightSpan[] spans = ssb.getSpans(0, ssb.length(), CustomLineHeightSpan.class);
if (spans.length >= 1) {
int lineHeight = spans[0].getLineHeight();
setSpan(ssb, new MarkdownLineHeightSpan(lineHeight * 1.5f), start, end);
float lineHeight = markdownStyle.getH1LineHeight();
if (lineHeight != -1) {
// NOTE: actually, we should also include "# " but it also works this way
setSpan(ssb, new MarkdownLineHeightSpan(lineHeight), start, end);
}
// NOTE: size span must be set after line height span to avoid height jumps
setSpan(ssb, new MarkdownFontSizeSpan(markdownStyle.getH1FontSize()), start, end);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public class MarkdownStyle {

private final float mH1FontSize;

private final float mH1LineHeight;

@NonNull
private final String mEmojiFontFamily;

Expand Down Expand Up @@ -79,6 +81,7 @@ public MarkdownStyle(@NonNull ReadableMap map, @NonNull Context context) {
mSyntaxColor = parseColor(map, "syntax", "color", context);
mLinkColor = parseColor(map, "link", "color", context);
mH1FontSize = parseFloat(map, "h1", "fontSize");
mH1LineHeight = parseFloat(map, "h1", "lineHeight");
mEmojiFontSize = parseFloat(map, "emoji", "fontSize");
mEmojiFontFamily = parseString(map, "emoji", "fontFamily");
mBlockquoteBorderColor = parseColor(map, "blockquote", "borderColor", context);
Expand Down Expand Up @@ -142,6 +145,10 @@ public float getH1FontSize() {
return mH1FontSize;
}

public float getH1LineHeight() {
return mH1LineHeight;
}

public float getEmojiFontSize() {
return mEmojiFontSize;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
import android.graphics.Paint;
import android.text.style.LineHeightSpan;

import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.views.text.internal.span.CustomLineHeightSpan;

public class MarkdownLineHeightSpan implements MarkdownSpan, LineHeightSpan {
private final float mLineHeight;
private final CustomLineHeightSpan mCustomLineHeightSpan;

public MarkdownLineHeightSpan(float lineHeight) {
mLineHeight = lineHeight;
mCustomLineHeightSpan = new CustomLineHeightSpan(PixelUtil.toPixelFromDIP(lineHeight));
}

@Override
public void chooseHeight(CharSequence text, int start, int end, int spanstartv, int lineHeight, Paint.FontMetricsInt fm) {
fm.top -= mLineHeight / 4;
fm.ascent -= mLineHeight / 4;
// CustomLineHeightSpan is marked as final, we can't extend it, but we can use it via this adapter
mCustomLineHeightSpan.chooseHeight(text, start, end, spanstartv, lineHeight, fm);
}
}
2 changes: 2 additions & 0 deletions apple/MarkdownBackedTextInputDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ - (void)textInputDidChangeSelection
NSMutableParagraphStyle *mutableParagraphStyle = [typingAttributes[NSParagraphStyleAttributeName] mutableCopy];
mutableParagraphStyle.firstLineHeadIndent = 0;
mutableParagraphStyle.headIndent = 0;
mutableParagraphStyle.minimumLineHeight = 0;
mutableParagraphStyle.maximumLineHeight = 0;
mutableTypingAttributes[NSParagraphStyleAttributeName] = mutableParagraphStyle;
_textView.typingAttributes = mutableTypingAttributes;
}
Expand Down
22 changes: 22 additions & 0 deletions apple/MarkdownFormatter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,28 @@ - (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedStri
paragraphStyle.headIndent = indent;
[attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range];
[attributedString addAttribute:RCTLiveMarkdownBlockquoteDepthAttributeName value:@(depth) range:range];
} else if (type == "h1" && markdownStyle.h1LineHeight != -1) {
__block BOOL found = NO;
[attributedString enumerateAttribute:NSParagraphStyleAttributeName
inRange:range
options:0
usingBlock:^(NSParagraphStyle *paragraphStyle, NSRange paragraphRange, BOOL *stop) {
if (paragraphStyle && paragraphStyle.headIndent && [paragraphStyle isKindOfClass:[NSMutableParagraphStyle class]]) {
NSMutableParagraphStyle *mutableParagraphStyle = (NSMutableParagraphStyle *)paragraphStyle;
mutableParagraphStyle.minimumLineHeight = markdownStyle.h1LineHeight;
mutableParagraphStyle.maximumLineHeight = markdownStyle.h1LineHeight;
found = YES;
*stop = YES;
}
}];
if (!found) {
NSParagraphStyle *defaultParagraphStyle = defaultTextAttributes[NSParagraphStyleAttributeName];
NSMutableParagraphStyle *paragraphStyle = defaultParagraphStyle != nil ? [defaultParagraphStyle mutableCopy] : [NSMutableParagraphStyle new];
paragraphStyle.minimumLineHeight = markdownStyle.h1LineHeight;
paragraphStyle.maximumLineHeight = markdownStyle.h1LineHeight;
NSRange rangeWithHashAndSpace = NSMakeRange(range.location - 2, range.length + 2);
[attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:rangeWithHashAndSpace];
}
} else if (type == "pre") {
[attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.preColor range:range];
NSRange rangeForBackground = [[attributedString string] characterAtIndex:range.location] == '\n' ? NSMakeRange(range.location + 1, range.length - 1) : range;
Expand Down
1 change: 1 addition & 0 deletions apple/RCTMarkdownStyle.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic) UIColor *syntaxColor;
@property (nonatomic) UIColor *linkColor;
@property (nonatomic) CGFloat h1FontSize;
@property (nonatomic) CGFloat h1LineHeight;
@property (nonatomic) CGFloat emojiFontSize;
@property (nonatomic) NSString *emojiFontFamily;
@property (nonatomic) UIColor *blockquoteBorderColor;
Expand Down
1 change: 1 addition & 0 deletions apple/RCTMarkdownStyle.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ - (instancetype)initWithStruct:(const facebook::react::MarkdownTextInputDecorato
_linkColor = RCTUIColorFromSharedColor(style.link.color);

_h1FontSize = style.h1.fontSize;
_h1LineHeight = style.h1.lineHeight;

_emojiFontSize = style.emoji.fontSize;
_emojiFontFamily = RCTNSStringFromString(style.emoji.fontFamily);
Expand Down
28 changes: 16 additions & 12 deletions example/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ GEM
base64
nkf
rexml
activesupport (7.1.4.2)
activesupport (7.2.2.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
algoliasearch (1.27.5)
Expand Down Expand Up @@ -64,30 +66,32 @@ GEM
cocoapods-try (1.2.0)
colored2 (3.1.2)
concurrent-ruby (1.3.3)
connection_pool (2.5.0)
connection_pool (2.5.2)
drb (2.2.1)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
ffi (1.17.1)
ffi (1.17.2)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
httpclient (2.9.0)
mutex_m
i18n (1.14.7)
concurrent-ruby (~> 1.0)
json (2.10.0)
json (2.11.1)
logger (1.7.0)
minitest (5.25.4)
minitest (5.25.5)
molinillo (0.8.0)
mutex_m (0.3.0)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
nkf (0.2.0)
public_suffix (4.0.7)
rexml (3.4.0)
rexml (3.4.1)
ruby-macho (2.5.1)
securerandom (0.4.1)
typhoeus (1.4.1)
ethon (>= 0.9.0)
tzinfo (2.0.6)
Expand All @@ -114,7 +118,7 @@ DEPENDENCIES
xcodeproj (< 1.26.0)

RUBY VERSION
ruby 3.3.5p100
ruby 3.2.2p53

BUNDLED WITH
2.4.22
2.6.3
10 changes: 5 additions & 5 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1654,7 +1654,7 @@ PODS:
- React-logger (= 0.79.2)
- React-perflogger (= 0.79.2)
- React-utils (= 0.79.2)
- RNLiveMarkdown (0.1.274):
- RNLiveMarkdown (0.1.275):
- DoubleConversion
- glog
- hermes-engine
Expand Down Expand Up @@ -2040,7 +2040,7 @@ SPEC CHECKSUMS:
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
hermes-engine: 0ff81d3257189f36a893ddd99f71a1eb0f49b12e
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
RCTDeprecation: 83ffb90c23ee5cea353bd32008a7bca100908f8c
RCTRequired: eb7c0aba998009f47a540bec9e9d69a54f68136e
Expand All @@ -2053,8 +2053,8 @@ SPEC CHECKSUMS:
React-debug: e74e76912b91e08d580c481c34881899ccf63da9
React-defaultsnativemodule: 11f6ee2cf69bf3af9d0f28a6253def33d21b5266
React-domnativemodule: f940bbc4fa9e134190acbf3a4a9f95621b5a8f51
React-Fabric: 6f5c357bf3a42ff11f8844ad3fc7a1eb04f4b9de
React-FabricComponents: 10e0c0209822ac9e69412913a8af1ca33573379b
React-Fabric: d7fa0d92efe15ca6707339d73f33ef0a2d9c371e
React-FabricComponents: ea938603a43a31714ee17c763b22156ef1e3afae
React-FabricImage: f582e764072dfa4715ae8c42979a5bace9cbcc12
React-featureflags: d5facceff8f8f6de430e0acecf4979a9a0839ba9
React-featureflagsnativemodule: a7dd141f1ef4b7c1331af0035689fbc742a49ff4
Expand Down Expand Up @@ -2103,7 +2103,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 04d5eb15eb46be6720e17a4a7fa92940a776e584
ReactCodegen: 913f5c1e00981f2366e36dcb9ae5caa01c2745f4
ReactCommon: 76d2dc87136d0a667678668b86f0fca0c16fdeb0
RNLiveMarkdown: 9ef67cf60c3caf0ee3e7f49fbeaeda8f519fb6bc
RNLiveMarkdown: 8e3138ad83b620aee2bf490f309312b335c6011b
RNReanimated: 8011ddcc1f6bc2fc1f27a5ac384d7495829ab6a0
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf
Expand Down
25 changes: 20 additions & 5 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ export default function App() {
const [linkColorState, setLinkColorState] = React.useState(false);
const [textFontSizeState, setTextFontSizeState] = React.useState(false);
const [emojiFontSizeState, setEmojiFontSizeState] = React.useState(false);
const [textLineHeightState, setTextLineHeightState] = React.useState(false);
const [headingLineHeightState, setHeadingLineHeightState] =
React.useState(false);
const [caretHidden, setCaretHidden] = React.useState(false);
const [selection, setSelection] = React.useState({start: 0, end: 0});

const style = React.useMemo(() => {
return {
color: textColorState ? 'gray' : 'black',
fontSize: textFontSizeState ? 15 : 20,
lineHeight: textLineHeightState ? 40 : undefined,
};
}, [textColorState, textFontSizeState]);
}, [textColorState, textFontSizeState, textLineHeightState]);

const markdownStyle = React.useMemo(() => {
return {
Expand All @@ -33,15 +37,16 @@ export default function App() {
link: {
color: linkColorState ? 'red' : 'blue',
},
h1: {
lineHeight: headingLineHeightState ? 60 : undefined,
},
};
}, [emojiFontSizeState, linkColorState]);
}, [emojiFontSizeState, linkColorState, headingLineHeightState]);

const ref = React.useRef<MarkdownTextInput>(null);

return (
<ScrollView
contentContainerStyle={styles.container}
style={styles.content}>
<ScrollView contentContainerStyle={styles.container} style={styles.content}>
<PlatformInfo />
<MarkdownTextInput
multiline={multiline}
Expand Down Expand Up @@ -120,6 +125,16 @@ export default function App() {
title="Toggle emoji font size"
onPress={() => setEmojiFontSizeState(prev => !prev)}
/>
<Button
title={`${textLineHeightState ? 'Disable' : 'Enable'} text line height`}
onPress={() => setTextLineHeightState(prev => !prev)}
/>
<Button
title={`${
headingLineHeightState ? 'Disable' : 'Enable'
} heading line height`}
onPress={() => setHeadingLineHeightState(prev => !prev)}
/>
<Button
title="Toggle caret hidden"
onPress={() => setCaretHidden(prev => !prev)}
Expand Down
57 changes: 57 additions & 0 deletions patches/react-native+0.79.2.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h
index 908cfc0..f693c17 100644
--- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h
+++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h
@@ -41,6 +41,8 @@ NSString *RCTNSStringFromStringApplyingTextTransform(NSString *string, facebook:

void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText);

+void RCTApplyBaselineOffsetForRange(NSMutableAttributedString *attributedText, NSRange attributedTextRange);
+
/*
* Whether two `NSAttributedString` lead to the same underlying displayed text, even if they are not strictly equal.
* I.e. is one string substitutable for the other when backing a control (which may have some ignorable attributes
diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm
index d08d0ba..21b32a1 100644
--- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm
+++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm
@@ -292,11 +292,20 @@ NSMutableDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttri
}

void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText)
+{
+ [attributedText.string enumerateSubstringsInRange:NSMakeRange(0, attributedText.length)
+ options:NSStringEnumerationByLines | NSStringEnumerationSubstringNotRequired
+ usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
+ RCTApplyBaselineOffsetForRange(attributedText, enclosingRange);
+ }];
+}
+
+void RCTApplyBaselineOffsetForRange(NSMutableAttributedString *attributedText, NSRange attributedTextRange)
{
__block CGFloat maximumLineHeight = 0;

[attributedText enumerateAttribute:NSParagraphStyleAttributeName
- inRange:NSMakeRange(0, attributedText.length)
+ inRange:attributedTextRange
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) {
if (!paragraphStyle) {
@@ -314,7 +323,7 @@ void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText)
__block CGFloat maximumFontLineHeight = 0;

[attributedText enumerateAttribute:NSFontAttributeName
- inRange:NSMakeRange(0, attributedText.length)
+ inRange:attributedTextRange
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (!font) {
@@ -332,7 +341,7 @@ void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText)

[attributedText addAttribute:NSBaselineOffsetAttributeName
value:@(baseLineOffset)
- range:NSMakeRange(0, attributedText.length)];
+ range:attributedTextRange];
}

static NSMutableAttributedString *RCTNSAttributedStringFragmentFromFragment(
1 change: 1 addition & 0 deletions src/MarkdownTextInputDecoratorViewNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface MarkdownStyle {
};
h1: {
fontSize: Float;
lineHeight: Float;
};
blockquote: {
borderColor: ColorValue;
Expand Down
1 change: 1 addition & 0 deletions src/styleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function makeDefaultMarkdownStyle(): MarkdownStyle {
},
h1: {
fontSize: 25,
lineHeight: -1,
},
emoji: {
fontSize: 20,
Expand Down
Loading