Skip to content

feat: add marginTop and marginBottom to headings and lists in htmlStyle - #838

Open
henrikra wants to merge 1 commit into
software-mansion:mainfrom
henrikra:feat/block-margins
Open

henrikra wants to merge 1 commit into
software-mansion:mainfrom
henrikra:feat/block-margins

Conversation

@henrikra

Copy link
Copy Markdown
Contributor

Summary

Closes #832. Follows up on #259, where vertical spacing between elements came up and you asked for a separate feature request.

Adds optional marginTop and marginBottom to htmlStyle for h1–h6, ul, ol and ulCheckbox, in both EnrichedTextInput and EnrichedText:

htmlStyle={{
  h2: { fontSize: 20, bold: true, marginTop: 18, marginBottom: 8 },
  ul: { marginTop: 6, marginBottom: 6 },
}}

Both default to 0, so nothing changes unless you set them. The names and behaviour match markdownStyle in react-native-enriched-markdown.

Behaviour (the same on all three platforms):

  • A list's margins go around the whole list. Consecutive items of one list are one block. Each heading paragraph is its own block.
  • Adjacent margins collapse into the larger one, like CSS: max(previous.marginBottom, next.marginTop).
  • No space is added above the first block or below the last one (like enriched-markdown's allowTrailingMargin: false).
  • The spacing is visual only. No characters are inserted, so the HTML, the plain text and selection offsets don't change. enriched-markdown adds top margins with spacer newlines. That works for a renderer, but in an editor those characters would end up in the text, so this PR uses paragraph styles and spans instead.

How it works

  • iOS: BlockSpacingUtils sets paragraphSpacingBefore / paragraphSpacing on the paragraphs next to each gap. It runs over the whole text after every styling pass in the input (anyTextMayHaveBeenModified) and after rendering in EnrichedText, because a block's spacing depends on its neighbours. It returns early when no margins are set, so existing users don't pay for it. The margins live in EnrichedConfig, keyed by StyleType, and scale with Dynamic Type like the heading font sizes. The gap goes on the block whose margin it is, so blockquote borders and code block backgrounds (drawn over whole line fragments) don't stretch into it. Bullets and checkboxes are drawn from usedRect, which leaves out paragraph spacing. I checked that with TextKit directly. The checkbox hit test used the line fragment rect, so I switched it to the used rect.
  • Android: EnrichedBlockSpacing computes the same gaps and keeps one EnrichedBlockSpacingSpan (LineHeightSpan + UpdateLayout) on each spaced paragraph. The span adds to the first line's ascent and the last line's descent. The pass only adds or removes spans whose values changed, so DynamicLayout reflows just the affected paragraphs. The input runs it from invalidateLayout(). EnrichedText runs it when it parses the value or the style changes. Both MeasurementStores run it too, so the measured height includes the spacing. EnrichedSpanFlags now orders the LineHeightSpans explicitly: paragraph spans (checkbox size) run first, then EnrichedLineHeightSpan, then the spacing span. So the margins add to the final line box. Before, the line-height span's order depended on when it was inserted. A checkbox tap in the margin above an item doesn't toggle it, matching the iOS hit test.
  • Web: CSS variables (--et-h1-margin-top etc.) feed the margin of the block elements. The browser collapses margins on its own. :first-child / :last-child rules drop the outer margins.
  • Types / codegen: new fields in HtmlStyle, both native component specs and DEFAULT_HTML_STYLE. They need to be in the defaults because Android's HtmlStyle requires every key.

Docs: the HtmlStyle reference has the new fields and a "Vertical spacing" section, and the styling guide shows an example. The example apps' main screens (native DevScreen, web App) use the margins. The test screens behind the E2E screenshots don't, so those screenshots stay the same.

Known limitation: on both platforms the caret on a block's first line is as tall as the line box, so it also covers the space above the line. That comes from the platform text views (caretRectForPosition / the Android cursor drawable both use the line height). I left it alone to keep this PR small.

Android detail: StaticLayout passes a line's adjusted ascent on to the next line of a wrapped paragraph, so the space above a heading would repeat between its own wrapped lines. EnrichedBlockSpacingSpan remembers what it added on the first line and undoes it on the next ones.

Not in this PR (happy to follow up if you want them): margins for p, blockquote and codeblock. A blockquote or code block draws its decoration over the whole line fragment, so it needs its own handling.

Test Plan

Use the example app's main screen with HTML that mixes the blocks, e.g.

<html><p>Intro paragraph</p><h3>Heading</h3><p>Body text</p><ul><li>One</li><li>Two</li></ul><h4>Next</h4><ol><li>First</li><li>Second</li></ol><blockquote><p>Quote</p></blockquote><h5>Last heading</h5></html>
  1. Load it as the input's value and in EnrichedText. Expected: the heading/list gaps are the collapsed values (e.g. list → h4 is max(8, 12) = 12). There is no extra space above "Intro paragraph" or below "Last heading". Items inside a list keep their normal spacing.
  2. Put the caret at the end of a heading and press Enter, then type. Expected: the new line is a plain paragraph with the heading's bottom margin above it. The HTML has no extra characters or tags.
  3. Toggle H1 on a plain paragraph and off again. Expected: the spacing shows up and goes away with the style.
  4. Remove all margins (or use the test screen). Expected: the layout is the same as on main.

Verified on:

  • iOS: iPhone 16 Pro Max simulator, iOS 18.5 (Xcode 16.4). EnrichedText and EnrichedTextInput: steps 1–3, plus Enter in the middle of a heading. I checked the emitted HTML and plain text after each edit.
  • Android: Medium_Phone_API_34 emulator. EnrichedText and EnrichedTextInput: steps 1–3 with the emitted HTML checked, plus a heading that wraps onto two lines (the margin is only added above the first line and below the last). A tap in the margin above a checkbox item doesn't toggle it, and a tap on the box does.
  • Web: the example-web build in headless Chromium (Playwright). Measured the gaps between blocks in the editor and in EnrichedText, the zero outer margins, and Enter after a heading. They match iOS.
  • yarn typecheck, yarn lint, yarn lint-clang:ios, ktlint, the web unit tests (new cases in htmlStyleToCSSVariables.test.ts) and the docs build pass.

Screenshots / Videos

Left: no margins (same as main). Right: with the example's margins. Same HTML.

iOS (iPhone 16 Pro Max, iOS 18.5) Android (API 34)
ios-before-after.png android-before-after.png

Compatibility

OS Implemented
iOS ✅
Android ✅
Web ✅

Checklist

  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

🤖 Generated with Claude Code

Adds optional vertical margins to h1–h6, ul, ol and ulCheckbox in htmlStyle,
for EnrichedTextInput and EnrichedText on iOS, Android and web. Adjacent
margins collapse into the larger one and no space is added above the first
block or below the last. The spacing is visual only: paragraph spacing on
iOS, a LineHeightSpan on Android and CSS margins on web, so the HTML, the
text and selection offsets don't change.

Closes software-mansion#832

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 23, 2026 02:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Unresolved checkbox hit-testing issues and stateful native line-height handling require corrections and final device-level validation.

2 open issues
What changed in this PR

Adds optional heading and list margins to htmlStyle for EnrichedTextInput and EnrichedText, addressing #832’s request for vertical spacing without changing document content.

Changes:

  • Extends public types, native specs, and defaults with zero-default margins.
  • Implements collapsed block spacing across iOS, Android, and web, including native measurement and checkbox hit-testing adjustments.
  • Documents the API and updates examples and CSS-variable tests.
File Description
src/​web/​styleConversion/​htmlStyleToCSSVariables.ts Converts margins to CSS variables.
src/​web/​styleConversion/​__tests__/​htmlStyleToCSSVariables.test.ts Tests margin-variable conversion.
src/​web/​EnrichedText.css Applies margins and removes outer spacing.
src/​utils/​defaultHtmlStyle.ts Adds zero-default margins.
src/​types.ts Exposes optional margin properties.
src/​spec/​EnrichedTextNativeComponent.ts Extends renderer native props.
src/​spec/​EnrichedTextInputNativeComponent.ts Extends editor native props.
ios/​utils/​CheckboxHitTestUtils.mm Uses used-line bounds for checkbox positioning.
ios/​utils/​BlockSpacingUtils.mm Computes collapsed paragraph spacing.
ios/​utils/​BlockSpacingUtils.h Declares the spacing utility.
ios/​EnrichedTextView.mm Applies renderer margin configuration.
ios/​EnrichedTextInputView.mm Refreshes spacing after editor styling.
ios/​config/​EnrichedConfig.mm Stores and scales block margins.
ios/​config/​EnrichedConfig.h Declares margin configuration methods.
docs/​docs/​core-functionalities/​styling-the-input.mdx Adds a spacing guide and example.
docs/​docs/​api-reference/​enriched-text-input.md Documents margin properties and behavior.
apps/​example/​src/​screens/​DevScreen.tsx Enables margins in the native demo editor.
apps/​example/​src/​constants/​editorConfig.ts Defines margin-enabled demo styles.
apps/​example/​src/​components/​TextRenderer.tsx Enables margins in the native demo renderer.
apps/​example-web/​src/​defaultHtmlStyle.ts Defines margin-enabled web demo styles.
apps/​example-web/​src/​components/​TextRenderer.tsx Enables margins in the web demo renderer.
apps/​example-web/​src/​App.tsx Enables margins in the web demo editor.
android/​src/​main/​java/​com/​swmansion/​enriched/​textinput/​utils/​Utils.kt Excludes spacing from checkbox tap bounds.
android/​src/​main/​java/​com/​swmansion/​enriched/​textinput/​styles/​HtmlStyle.kt Parses editor margins.
android/​src/​main/​java/​com/​swmansion/​enriched/​textinput/​MeasurementStore.kt Includes spacing in editor measurement.
android/​src/​main/​java/​com/​swmansion/​enriched/​textinput/​EnrichedTextInputViewLayoutManager.kt Refreshes spacing during layout invalidation.
android/​src/​main/​java/​com/​swmansion/​enriched/​textinput/​EnrichedTextInputView.kt Integrates spacing and line-height priorities.
android/​src/​main/​java/​com/​swmansion/​enriched/​text/​MeasurementStore.kt Includes spacing in renderer measurement.
android/​src/​main/​java/​com/​swmansion/​enriched/​text/​EnrichedTextView.kt Applies spacing after parsing and style updates.
android/​src/​main/​java/​com/​swmansion/​enriched/​text/​EnrichedTextStyle.kt Parses renderer margins.
android/​src/​main/​java/​com/​swmansion/​enriched/​common/​spans/​EnrichedBlockSpacingSpan.kt Adds spacing to paragraph boundary lines.
android/​src/​main/​java/​com/​swmansion/​enriched/​common/​EnrichedStyle.kt Extends the shared style contract.
android/​src/​main/​java/​com/​swmansion/​enriched/​common/​EnrichedSpanFlags.kt Orders line-height and spacing spans.
android/​src/​main/​java/​com/​swmansion/​enriched/​common/​EnrichedBlockSpacing.kt Computes and updates collapsed spacing spans.

Review effort: Balanced

Comment on lines +67 to +69
CGRect lineRect =
[layoutManager lineFragmentUsedRectForGlyphAtIndex:glyphIndex
effectiveRange:nil];
Comment on lines +67 to 71
CGRect lineRect =
[layoutManager lineFragmentUsedRectForGlyphAtIndex:glyphIndex
effectiveRange:nil];

CGFloat originX = lineRect.origin.x + config.checkboxListMarginLeft;

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature request] Vertical spacing (margins) for headings and lists in htmlStyle

2 participants