From 88323d37c58ad0ef3bdaf4abf2af28222a3b0043 Mon Sep 17 00:00:00 2001 From: Henrik Raitasola Date: Fri, 23 Oct 2026 00:47:37 +0700 Subject: [PATCH] feat: add marginTop and marginBottom to headings and lists in htmlStyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #832 Co-Authored-By: Claude Opus 5.5 --- .../enriched/common/EnrichedBlockSpacing.kt | 206 ++++++++++++++++++ .../enriched/common/EnrichedSpanFlags.kt | 8 + .../enriched/common/EnrichedStyle.kt | 18 ++ .../common/spans/EnrichedBlockSpacingSpan.kt | 45 ++++ .../enriched/text/EnrichedTextStyle.kt | 36 +++ .../enriched/text/EnrichedTextView.kt | 11 +- .../enriched/text/MeasurementStore.kt | 12 +- .../textinput/EnrichedTextInputView.kt | 12 +- .../EnrichedTextInputViewLayoutManager.kt | 2 + .../enriched/textinput/MeasurementStore.kt | 12 +- .../enriched/textinput/styles/HtmlStyle.kt | 36 +++ .../enriched/textinput/utils/Utils.kt | 10 +- apps/example-web/src/App.tsx | 4 +- .../src/components/TextRenderer.tsx | 4 +- apps/example-web/src/defaultHtmlStyle.ts | 19 ++ apps/example/src/components/TextRenderer.tsx | 4 +- apps/example/src/constants/editorConfig.ts | 33 +++ apps/example/src/screens/DevScreen.tsx | 4 +- .../docs/api-reference/enriched-text-input.md | 50 +++++ .../styling-the-input.mdx | 37 +++- ios/EnrichedTextInputView.mm | 47 ++++ ios/EnrichedTextView.mm | 45 ++++ ios/config/EnrichedConfig.h | 9 + ios/config/EnrichedConfig.mm | 43 ++++ ios/utils/BlockSpacingUtils.h | 11 + ios/utils/BlockSpacingUtils.mm | 145 ++++++++++++ ios/utils/CheckboxHitTestUtils.mm | 7 +- src/spec/EnrichedTextInputNativeComponent.ts | 8 + src/spec/EnrichedTextNativeComponent.ts | 8 + src/types.ts | 8 + src/utils/defaultHtmlStyle.ts | 18 ++ src/web/EnrichedText.css | 32 ++- .../__tests__/htmlStyleToCSSVariables.test.ts | 19 ++ .../htmlStyleToCSSVariables.ts | 14 ++ 34 files changed, 933 insertions(+), 44 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/common/EnrichedBlockSpacing.kt create mode 100644 android/src/main/java/com/swmansion/enriched/common/spans/EnrichedBlockSpacingSpan.kt create mode 100644 ios/utils/BlockSpacingUtils.h create mode 100644 ios/utils/BlockSpacingUtils.mm diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedBlockSpacing.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedBlockSpacing.kt new file mode 100644 index 000000000..e393f7b43 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedBlockSpacing.kt @@ -0,0 +1,206 @@ +package com.swmansion.enriched.common + +import android.text.Spannable +import com.swmansion.enriched.common.spans.EnrichedBlockSpacingSpan +import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan +import com.swmansion.enriched.common.spans.EnrichedH1Span +import com.swmansion.enriched.common.spans.EnrichedH2Span +import com.swmansion.enriched.common.spans.EnrichedH3Span +import com.swmansion.enriched.common.spans.EnrichedH4Span +import com.swmansion.enriched.common.spans.EnrichedH5Span +import com.swmansion.enriched.common.spans.EnrichedH6Span +import com.swmansion.enriched.common.spans.EnrichedOrderedListSpan +import com.swmansion.enriched.common.spans.EnrichedUnorderedListSpan +import com.swmansion.enriched.common.spans.interfaces.EnrichedParagraphSpan + +// Applies the heading and list margins from EnrichedStyle as EnrichedBlockSpacingSpans. +// Adjacent margins collapse into the larger one and the first and last blocks get no outer space. +// Only spans change, never the characters, so the HTML, the plain text and selection offsets stay the same. +object EnrichedBlockSpacing { + private class Paragraph( + val start: Int, + val end: Int, + // Consecutive items of one list form a single block, while every heading paragraph is a block of its own. + val listClass: Class<*>?, + val marginTop: Int, + val marginBottom: Int, + ) + + private data class Spacing( + val start: Int, + val end: Int, + val before: Int, + val after: Int, + ) + + // Returns true if any spacing span was added or removed. + fun apply( + spannable: Spannable, + style: EnrichedStyle, + ): Boolean { + val wanted = if (style.hasBlockMargins()) getSpacings(spannable, style).toMutableSet() else mutableSetOf() + var changed = false + + for (span in spannable.getSpans(0, spannable.length, EnrichedBlockSpacingSpan::class.java)) { + val current = Spacing(spannable.getSpanStart(span), spannable.getSpanEnd(span), span.spacingBefore, span.spacingAfter) + if (!wanted.remove(current)) { + spannable.removeSpan(span) + changed = true + } + } + + for (spacing in wanted) { + val span = EnrichedBlockSpacingSpan(spacing.before, spacing.after) + spannable.setSpan(span, spacing.start, spacing.end, EnrichedSpanFlags.forSpan(span)) + changed = true + } + + return changed + } + + private fun getSpacings( + spannable: Spannable, + style: EnrichedStyle, + ): List { + val paragraphs = getParagraphs(spannable, style) + val before = IntArray(paragraphs.size) + val after = IntArray(paragraphs.size) + + for (i in 0 until paragraphs.size - 1) { + val previous = paragraphs[i] + val next = paragraphs[i + 1] + if (previous.listClass != null && previous.listClass == next.listClass) continue + + // The gap goes on the block whose margin it is, so that blockquote and code block decorations + // don't stretch into it. + if (next.marginTop >= previous.marginBottom) { + before[i + 1] = next.marginTop + } else { + after[i] = previous.marginBottom + } + } + + return paragraphs.indices + .filter { (before[it] > 0 || after[it] > 0) && paragraphs[it].end > paragraphs[it].start } + .map { Spacing(paragraphs[it].start, paragraphs[it].end, before[it], after[it]) } + } + + private class Block( + // Headings come before lists if a paragraph has both, e.g. while an edit is in progress + val rank: Int, + val listClass: Class<*>?, + val marginTop: Int, + val marginBottom: Int, + ) + + private fun getParagraphs( + spannable: Spannable, + style: EnrichedStyle, + ): List { + val bounds = mutableListOf>() + var start = 0 + + // A trailing newline leaves an empty last line, which is laid out as one more paragraph. + while (start <= spannable.length) { + val newline = spannable.indexOf('\n', start) + val end = if (newline == -1) spannable.length else newline + bounds.add(Pair(start, end)) + if (newline == -1) break + start = newline + 1 + } + + val blocks = arrayOfNulls(bounds.size) + val paragraphStarts = IntArray(bounds.size) { bounds[it].first } + + for (span in spannable.getSpans(0, spannable.length, EnrichedParagraphSpan::class.java)) { + val block = getBlock(span, style) ?: continue + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + + // A span normally covers one paragraph, but it can cover more while an edit is in progress + var index = paragraphStarts.binarySearch(spanStart).let { if (it >= 0) it else -it - 2 }.coerceAtLeast(0) + while (index < bounds.size && bounds[index].first < spanEnd) { + val current = blocks[index] + // Empty headings and list items hold a zero-width space, so an empty paragraph stays plain text. + val overlaps = bounds[index].second > maxOf(bounds[index].first, spanStart) + if (overlaps && (current == null || block.rank < current.rank)) { + blocks[index] = block + } + index++ + } + } + + return bounds.mapIndexed { i, (start, end) -> + val block = blocks[i] + Paragraph(start, end, block?.listClass, block?.marginTop ?: 0, block?.marginBottom ?: 0) + } + } + + private fun getBlock( + span: EnrichedParagraphSpan, + style: EnrichedStyle, + ): Block? = + when (span) { + is EnrichedH1Span -> { + Block(0, null, style.h1MarginTop, style.h1MarginBottom) + } + + is EnrichedH2Span -> { + Block(1, null, style.h2MarginTop, style.h2MarginBottom) + } + + is EnrichedH3Span -> { + Block(2, null, style.h3MarginTop, style.h3MarginBottom) + } + + is EnrichedH4Span -> { + Block(3, null, style.h4MarginTop, style.h4MarginBottom) + } + + is EnrichedH5Span -> { + Block(4, null, style.h5MarginTop, style.h5MarginBottom) + } + + is EnrichedH6Span -> { + Block(5, null, style.h6MarginTop, style.h6MarginBottom) + } + + is EnrichedUnorderedListSpan -> { + Block(6, EnrichedUnorderedListSpan::class.java, style.ulMarginTop, style.ulMarginBottom) + } + + is EnrichedOrderedListSpan -> { + Block(7, EnrichedOrderedListSpan::class.java, style.olMarginTop, style.olMarginBottom) + } + + is EnrichedCheckboxListSpan -> { + Block(8, EnrichedCheckboxListSpan::class.java, style.ulCheckboxMarginTop, style.ulCheckboxMarginBottom) + } + + else -> { + null + } + } + + private fun EnrichedStyle.hasBlockMargins(): Boolean = + intArrayOf( + h1MarginTop, + h1MarginBottom, + h2MarginTop, + h2MarginBottom, + h3MarginTop, + h3MarginBottom, + h4MarginTop, + h4MarginBottom, + h5MarginTop, + h5MarginBottom, + h6MarginTop, + h6MarginBottom, + ulMarginTop, + ulMarginBottom, + olMarginTop, + olMarginBottom, + ulCheckboxMarginTop, + ulCheckboxMarginBottom, + ).any { it > 0 } +} diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt index d9a0ddb50..e2781ed93 100644 --- a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt @@ -2,14 +2,20 @@ package com.swmansion.enriched.common import android.text.Spannable import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.common.spans.EnrichedBlockSpacingSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan +import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan // Higher priority spans are processed first, so styles with lower priorities are painted on top of previously applied styles. // For example, inline styles are applied on top of paragraph styles, allowing them to override paragraph-level styling. // Alignment styles are applied last, ensuring they position the final, fully styled text. +// Among line height spans, paragraph spans (checkbox size) go first, then the line height, then block spacing, +// so the margins add to the final line box. object EnrichedSpanFlags { private const val ALIGNMENT_SPAN_PRIORITY = 0 + private const val BLOCK_SPACING_SPAN_PRIORITY = 0 private const val INLINE_SPAN_PRIORITY = 1 + private const val LINE_HEIGHT_SPAN_PRIORITY = 1 private const val PARAGRAPH_SPAN_PRIORITY = 2 @JvmStatic @@ -21,7 +27,9 @@ object EnrichedSpanFlags { val priority = when (span) { is EnrichedAlignmentSpan -> ALIGNMENT_SPAN_PRIORITY + is EnrichedBlockSpacingSpan -> BLOCK_SPACING_SPAN_PRIORITY is EnrichedInlineSpan -> INLINE_SPAN_PRIORITY + is EnrichedLineHeightSpan -> LINE_HEIGHT_SPAN_PRIORITY else -> PARAGRAPH_SPAN_PRIORITY } return applyPriority(baseFlags, priority) diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedStyle.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedStyle.kt index 83e24903d..faf5fc240 100644 --- a/android/src/main/java/com/swmansion/enriched/common/EnrichedStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedStyle.kt @@ -4,16 +4,28 @@ interface EnrichedStyle { // Headers val h1FontSize: Int val h1Bold: Boolean + val h1MarginTop: Int + val h1MarginBottom: Int val h2FontSize: Int val h2Bold: Boolean + val h2MarginTop: Int + val h2MarginBottom: Int val h3FontSize: Int val h3Bold: Boolean + val h3MarginTop: Int + val h3MarginBottom: Int val h4FontSize: Int val h4Bold: Boolean + val h4MarginTop: Int + val h4MarginBottom: Int val h5FontSize: Int val h5Bold: Boolean + val h5MarginTop: Int + val h5MarginBottom: Int val h6FontSize: Int val h6Bold: Boolean + val h6MarginTop: Int + val h6MarginBottom: Int // Blockquote val blockquoteColor: Int? @@ -24,12 +36,16 @@ interface EnrichedStyle { // Ordered Lists val olGapWidth: Int val olMarginLeft: Int + val olMarginTop: Int + val olMarginBottom: Int val olMarkerFontWeight: Int? val olMarkerColor: Int? // Unordered Lists val ulGapWidth: Int val ulMarginLeft: Int + val ulMarginTop: Int + val ulMarginBottom: Int val ulBulletSize: Int val ulBulletColor: Int @@ -37,6 +53,8 @@ interface EnrichedStyle { val ulCheckboxBoxSize: Int val ulCheckboxGapWidth: Int val ulCheckboxMarginLeft: Int + val ulCheckboxMarginTop: Int + val ulCheckboxMarginBottom: Int val ulCheckboxBoxColor: Int // Links diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedBlockSpacingSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedBlockSpacingSpan.kt new file mode 100644 index 000000000..99639eadc --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedBlockSpacingSpan.kt @@ -0,0 +1,45 @@ +package com.swmansion.enriched.common.spans + +import android.graphics.Paint +import android.text.Spanned +import android.text.style.LineHeightSpan +import android.text.style.UpdateLayout + +// Adds vertical space above the first line and below the last line of the paragraph it covers. +// Set by EnrichedBlockSpacing, which works out the collapsed margins between blocks. +class EnrichedBlockSpacingSpan( + val spacingBefore: Int, + val spacingAfter: Int, +) : LineHeightSpan, + UpdateLayout { + // StaticLayout carries the first line's adjusted ascent over to the next line of a wrapped + // paragraph, which would repeat the space above every wrapped line. These remember the first + // line's ascent before and after the spacing, so the next lines can undo it. + private var firstLineAscent = 0 + private var spacedFirstLineAscent = 0 + + override fun chooseHeight( + text: CharSequence, + start: Int, + end: Int, + spanstartv: Int, + lineHeight: Int, + fm: Paint.FontMetricsInt, + ) { + val spanned = text as? Spanned ?: return + + if (start == spanned.getSpanStart(this)) { + firstLineAscent = fm.ascent + fm.ascent -= spacingBefore + spacedFirstLineAscent = fm.ascent + fm.top = minOf(fm.top, fm.ascent) + } else if (spacingBefore > 0 && fm.ascent == spacedFirstLineAscent) { + fm.ascent = firstLineAscent + } + + if (end >= spanned.getSpanEnd(this)) { + fm.descent += spacingAfter + fm.bottom = maxOf(fm.bottom, fm.descent) + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt index d1ee9b4ff..a760965de 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt @@ -14,16 +14,28 @@ data class EnrichedTextStyle( // Headings override val h1FontSize: Int, override val h1Bold: Boolean, + override val h1MarginTop: Int, + override val h1MarginBottom: Int, override val h2FontSize: Int, override val h2Bold: Boolean, + override val h2MarginTop: Int, + override val h2MarginBottom: Int, override val h3FontSize: Int, override val h3Bold: Boolean, + override val h3MarginTop: Int, + override val h3MarginBottom: Int, override val h4FontSize: Int, override val h4Bold: Boolean, + override val h4MarginTop: Int, + override val h4MarginBottom: Int, override val h5FontSize: Int, override val h5Bold: Boolean, + override val h5MarginTop: Int, + override val h5MarginBottom: Int, override val h6FontSize: Int, override val h6Bold: Boolean, + override val h6MarginTop: Int, + override val h6MarginBottom: Int, // Blockquote override val blockquoteColor: Int?, override val blockquoteBorderColor: Int, @@ -32,11 +44,15 @@ data class EnrichedTextStyle( // Ordered List override val olGapWidth: Int, override val olMarginLeft: Int, + override val olMarginTop: Int, + override val olMarginBottom: Int, override val olMarkerFontWeight: Int?, override val olMarkerColor: Int?, // Unordered List override val ulGapWidth: Int, override val ulMarginLeft: Int, + override val ulMarginTop: Int, + override val ulMarginBottom: Int, override val ulBulletSize: Int, override val ulBulletColor: Int, // Checkbox List @@ -44,6 +60,8 @@ data class EnrichedTextStyle( override val ulCheckboxBoxSize: Int, override val ulCheckboxGapWidth: Int, override val ulCheckboxMarginLeft: Int, + override val ulCheckboxMarginTop: Int, + override val ulCheckboxMarginBottom: Int, // Links override val aColor: Int, override val aUnderline: Boolean, @@ -83,32 +101,50 @@ data class EnrichedTextStyle( return EnrichedTextStyle( h1FontSize = parseFloat(h1, "fontSize", allowFontScaling).toInt(), h1Bold = h1?.getBoolean("bold") ?: false, + h1MarginTop = parseFloat(h1, "marginTop", allowFontScaling).toInt(), + h1MarginBottom = parseFloat(h1, "marginBottom", allowFontScaling).toInt(), h2FontSize = parseFloat(h2, "fontSize", allowFontScaling).toInt(), h2Bold = h2?.getBoolean("bold") ?: false, + h2MarginTop = parseFloat(h2, "marginTop", allowFontScaling).toInt(), + h2MarginBottom = parseFloat(h2, "marginBottom", allowFontScaling).toInt(), h3FontSize = parseFloat(h3, "fontSize", allowFontScaling).toInt(), h3Bold = h3?.getBoolean("bold") ?: false, + h3MarginTop = parseFloat(h3, "marginTop", allowFontScaling).toInt(), + h3MarginBottom = parseFloat(h3, "marginBottom", allowFontScaling).toInt(), h4FontSize = parseFloat(h4, "fontSize", allowFontScaling).toInt(), h4Bold = h4?.getBoolean("bold") ?: false, + h4MarginTop = parseFloat(h4, "marginTop", allowFontScaling).toInt(), + h4MarginBottom = parseFloat(h4, "marginBottom", allowFontScaling).toInt(), h5FontSize = parseFloat(h5, "fontSize", allowFontScaling).toInt(), h5Bold = h5?.getBoolean("bold") ?: false, + h5MarginTop = parseFloat(h5, "marginTop", allowFontScaling).toInt(), + h5MarginBottom = parseFloat(h5, "marginBottom", allowFontScaling).toInt(), h6FontSize = parseFloat(h6, "fontSize", allowFontScaling).toInt(), h6Bold = h6?.getBoolean("bold") ?: false, + h6MarginTop = parseFloat(h6, "marginTop", allowFontScaling).toInt(), + h6MarginBottom = parseFloat(h6, "marginBottom", allowFontScaling).toInt(), blockquoteColor = parseOptionalColor(context, blockquote, "color"), blockquoteBorderColor = parseColor(context, blockquote, "borderColor"), blockquoteStripeWidth = parseFloat(blockquote, "borderWidth", allowFontScaling).toInt(), blockquoteGapWidth = parseFloat(blockquote, "gapWidth", allowFontScaling).toInt(), olGapWidth = parseFloat(orderedList, "gapWidth", allowFontScaling).toInt(), olMarginLeft = calculateOlMarginLeft(fontSize, parseFloat(orderedList, "marginLeft", allowFontScaling).toInt()), + olMarginTop = parseFloat(orderedList, "marginTop", allowFontScaling).toInt(), + olMarginBottom = parseFloat(orderedList, "marginBottom", allowFontScaling).toInt(), olMarkerFontWeight = parseOptionalFontWeight(orderedList, "markerFontWeight"), olMarkerColor = parseOptionalColor(context, orderedList, "markerColor"), ulGapWidth = parseFloat(unorderedList, "gapWidth", allowFontScaling).toInt(), ulMarginLeft = parseFloat(unorderedList, "marginLeft", allowFontScaling).toInt(), + ulMarginTop = parseFloat(unorderedList, "marginTop", allowFontScaling).toInt(), + ulMarginBottom = parseFloat(unorderedList, "marginBottom", allowFontScaling).toInt(), ulBulletSize = parseFloat(unorderedList, "bulletSize", allowFontScaling).toInt(), ulBulletColor = parseColor(context, unorderedList, "bulletColor"), ulCheckboxBoxColor = parseColor(context, checkboxList, "boxColor"), ulCheckboxBoxSize = parseFloat(checkboxList, "boxSize", allowFontScaling).toInt(), ulCheckboxGapWidth = parseFloat(checkboxList, "gapWidth", allowFontScaling).toInt(), ulCheckboxMarginLeft = parseFloat(checkboxList, "marginLeft", allowFontScaling).toInt(), + ulCheckboxMarginTop = parseFloat(checkboxList, "marginTop", allowFontScaling).toInt(), + ulCheckboxMarginBottom = parseFloat(checkboxList, "marginBottom", allowFontScaling).toInt(), aColor = parseColor(context, link, "color"), aUnderline = parseIsUnderline(link), aPressColor = parseColor(context, link, "pressColor"), diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt index 357016da3..964944658 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt @@ -22,6 +22,7 @@ import com.facebook.react.uimanager.ViewDefaults import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight +import com.swmansion.enriched.common.EnrichedBlockSpacing import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.GumboNormalizer @@ -181,6 +182,7 @@ class EnrichedTextView : AppCompatTextView { val parsed = parseText(text, style) if (parsed != null) { + (parsed as? Spannable)?.let { EnrichedBlockSpacing.apply(it, style) } parsedText = parsed setText(parsed, BufferType.NORMAL) observeAsyncImages() @@ -286,6 +288,10 @@ class EnrichedTextView : AppCompatTextView { modified = true } + if (EnrichedBlockSpacing.apply(spannable, enrichedStyle)) { + modified = true + } + if (modified) { this.text = spannable } @@ -323,11 +329,12 @@ class EnrichedTextView : AppCompatTextView { .forEach { spannable.removeSpan(it) } lineHeight?.let { + val lineHeightSpan = EnrichedLineHeightSpan(it, allowFontScaling) spannable.setSpan( - EnrichedLineHeightSpan(it, allowFontScaling), + lineHeightSpan, 0, spannable.length, - Spannable.SPAN_INCLUSIVE_INCLUSIVE, + EnrichedSpanFlags.forSpan(lineHeightSpan, Spannable.SPAN_INCLUSIVE_INCLUSIVE), ) } diff --git a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt index f2805659c..6d9e24799 100644 --- a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt @@ -18,7 +18,9 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.facebook.yoga.YogaMeasureMode import com.facebook.yoga.YogaMeasureOutput +import com.swmansion.enriched.common.EnrichedBlockSpacing import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.allowFontScalingFromProps import com.swmansion.enriched.common.parser.EnrichedParser @@ -122,8 +124,9 @@ object MeasurementStore { val enrichedStyle = EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize, style, allowFontScaling) val factory = EnrichedTextSpanFactory() - val parsed = EnrichedParser.fromHtml(textToParse, enrichedStyle, factory) - return parsed.trimEnd('\n') + val parsed = EnrichedParser.fromHtml(textToParse, enrichedStyle, factory).trimEnd('\n') + (parsed as? Spannable)?.let { EnrichedBlockSpacing.apply(it, enrichedStyle) } + return parsed } catch (e: Exception) { Log.w("MeasurementStore", "Error parsing initial HTML text: ${e.message}") return text @@ -168,11 +171,12 @@ object MeasurementStore { val measuredText: CharSequence = if (lineHeight > 0f) { val spannable = SpannableString(rawText) + val lineHeightSpan = EnrichedLineHeightSpan(lineHeight, allowFontScaling) spannable.setSpan( - EnrichedLineHeightSpan(lineHeight, allowFontScaling), + lineHeightSpan, 0, spannable.length, - Spannable.SPAN_INCLUSIVE_INCLUSIVE, + EnrichedSpanFlags.forSpan(lineHeightSpan, Spannable.SPAN_INCLUSIVE_INCLUSIVE), ) spannable } else { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index e61435f35..2c1e8b3fd 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -38,6 +38,7 @@ import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight +import com.swmansion.enriched.common.EnrichedBlockSpacing import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.GumboNormalizer @@ -598,14 +599,21 @@ class EnrichedTextInputView : .forEach { spannable.removeSpan(it) } val lh = lineHeight ?: return + val lineHeightSpan = EnrichedLineHeightSpan(lh, allowFontScaling) spannable.setSpan( - EnrichedLineHeightSpan(lh, allowFontScaling), + lineHeightSpan, 0, spannable.length, - Spannable.SPAN_INCLUSIVE_INCLUSIVE, + EnrichedSpanFlags.forSpan(lineHeightSpan, Spannable.SPAN_INCLUSIVE_INCLUSIVE), ) } + // Block spacing depends on the neighbouring paragraphs, so it is recomputed for the whole text + fun refreshBlockSpacing() { + val spannable = text as? Spannable ?: return + EnrichedBlockSpacing.apply(spannable, htmlStyle) + } + fun setFontFamily(family: String?) { if (family != fontFamily) { fontFamily = family diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewLayoutManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewLayoutManager.kt index 0d20e7632..2d9541eff 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewLayoutManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewLayoutManager.kt @@ -8,6 +8,8 @@ class EnrichedTextInputViewLayoutManager( private var forceHeightRecalculationCounter: Int = 0 fun invalidateLayout() { + view.refreshBlockSpacing() + val stateWrapper = view.stateWrapper ?: return val text = view.text diff --git a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt index ea309ef8a..747e0f3fa 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt @@ -16,6 +16,8 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.facebook.yoga.YogaMeasureMode import com.facebook.yoga.YogaMeasureOutput +import com.swmansion.enriched.common.EnrichedBlockSpacing +import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.allowFontScalingFromProps import com.swmansion.enriched.common.parser.EnrichedParser import com.swmansion.enriched.common.pixelFromSpOrDp @@ -117,8 +119,9 @@ object MeasurementStore { try { val htmlStyle = HtmlStyle(defaultView, props.getMap("htmlStyle")) val factory = EnrichedTextInputSpannableFactory() - val parsed = EnrichedParser.fromHtml(defaultValue, htmlStyle, factory) - return parsed.trimEnd('\n') + val parsed = EnrichedParser.fromHtml(defaultValue, htmlStyle, factory).trimEnd('\n') + (parsed as? Spannable)?.let { EnrichedBlockSpacing.apply(it, htmlStyle) } + return parsed } catch (e: Exception) { Log.w("MeasurementStore", "Error parsing initial HTML text: ${e.message}") return defaultValue @@ -159,11 +162,12 @@ object MeasurementStore { val text: CharSequence = if (lineHeight > 0f) { val spannable = SpannableString(rawText) + val lineHeightSpan = EnrichedLineHeightSpan(lineHeight, allowFontScaling) spannable.setSpan( - EnrichedLineHeightSpan(lineHeight, allowFontScaling), + lineHeightSpan, 0, spannable.length, - Spannable.SPAN_INCLUSIVE_INCLUSIVE, + EnrichedSpanFlags.forSpan(lineHeightSpan, Spannable.SPAN_INCLUSIVE_INCLUSIVE), ) spannable } else { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt index 5a772ded9..07fac3f71 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt @@ -24,21 +24,33 @@ class HtmlStyle : EnrichedStyle { // JS passes them as a prop - so they are initialized after the constructor is called. override var h1FontSize: Int = 72 override var h1Bold: Boolean = false + override var h1MarginTop: Int = 0 + override var h1MarginBottom: Int = 0 override var h2FontSize: Int = 64 override var h2Bold: Boolean = false + override var h2MarginTop: Int = 0 + override var h2MarginBottom: Int = 0 override var h3FontSize: Int = 56 override var h3Bold: Boolean = false + override var h3MarginTop: Int = 0 + override var h3MarginBottom: Int = 0 override var h4FontSize: Int = 48 override var h4Bold: Boolean = false + override var h4MarginTop: Int = 0 + override var h4MarginBottom: Int = 0 override var h5FontSize: Int = 40 override var h5Bold: Boolean = false + override var h5MarginTop: Int = 0 + override var h5MarginBottom: Int = 0 override var h6FontSize: Int = 32 override var h6Bold: Boolean = false + override var h6MarginTop: Int = 0 + override var h6MarginBottom: Int = 0 override var blockquoteColor: Int? = null override var blockquoteBorderColor: Int = Color.BLACK @@ -47,17 +59,23 @@ class HtmlStyle : EnrichedStyle { override var olGapWidth: Int = 16 override var olMarginLeft: Int = 24 + override var olMarginTop: Int = 0 + override var olMarginBottom: Int = 0 override var olMarkerFontWeight: Int? = null override var olMarkerColor: Int? = null override var ulGapWidth: Int = 16 override var ulMarginLeft: Int = 24 + override var ulMarginTop: Int = 0 + override var ulMarginBottom: Int = 0 override var ulBulletSize: Int = 8 override var ulBulletColor: Int = Color.BLACK override var ulCheckboxBoxSize: Int = 50 override var ulCheckboxGapWidth: Int = 16 override var ulCheckboxMarginLeft: Int = 24 + override var ulCheckboxMarginTop: Int = 0 + override var ulCheckboxMarginBottom: Int = 0 override var ulCheckboxBoxColor: Int = Color.BLACK override var aColor: Int = Color.BLACK @@ -85,26 +103,38 @@ class HtmlStyle : EnrichedStyle { val h1Style = style.getMap("h1") h1FontSize = parseFloat(h1Style, "fontSize").toInt() h1Bold = h1Style?.getBoolean("bold") == true + h1MarginTop = parseFloat(h1Style, "marginTop").toInt() + h1MarginBottom = parseFloat(h1Style, "marginBottom").toInt() val h2Style = style.getMap("h2") h2FontSize = parseFloat(h2Style, "fontSize").toInt() h2Bold = h2Style?.getBoolean("bold") == true + h2MarginTop = parseFloat(h2Style, "marginTop").toInt() + h2MarginBottom = parseFloat(h2Style, "marginBottom").toInt() val h3Style = style.getMap("h3") h3FontSize = parseFloat(h3Style, "fontSize").toInt() h3Bold = h3Style?.getBoolean("bold") == true + h3MarginTop = parseFloat(h3Style, "marginTop").toInt() + h3MarginBottom = parseFloat(h3Style, "marginBottom").toInt() val h4Style = style.getMap("h4") h4FontSize = parseFloat(h4Style, "fontSize").toInt() h4Bold = h4Style?.getBoolean("bold") == true + h4MarginTop = parseFloat(h4Style, "marginTop").toInt() + h4MarginBottom = parseFloat(h4Style, "marginBottom").toInt() val h5Style = style.getMap("h5") h5FontSize = parseFloat(h5Style, "fontSize").toInt() h5Bold = h5Style?.getBoolean("bold") == true + h5MarginTop = parseFloat(h5Style, "marginTop").toInt() + h5MarginBottom = parseFloat(h5Style, "marginBottom").toInt() val h6Style = style.getMap("h6") h6FontSize = parseFloat(h6Style, "fontSize").toInt() h6Bold = h6Style?.getBoolean("bold") == true + h6MarginTop = parseFloat(h6Style, "marginTop").toInt() + h6MarginBottom = parseFloat(h6Style, "marginBottom").toInt() val blockquoteStyle = style.getMap("blockquote") blockquoteColor = parseOptionalColor(blockquoteStyle, "color") @@ -117,6 +147,8 @@ class HtmlStyle : EnrichedStyle { val calculatedMarginLeft = calculateOlMarginLeft(view, userDefinedMarginLeft) olMarginLeft = calculatedMarginLeft olGapWidth = parseFloat(olStyle, "gapWidth").toInt() + olMarginTop = parseFloat(olStyle, "marginTop").toInt() + olMarginBottom = parseFloat(olStyle, "marginBottom").toInt() olMarkerColor = parseOptionalColor(olStyle, "markerColor") olMarkerFontWeight = parseOptionalFontWeight(olStyle, "markerFontWeight") @@ -124,12 +156,16 @@ class HtmlStyle : EnrichedStyle { ulBulletColor = parseColor(ulStyle, "bulletColor") ulGapWidth = parseFloat(ulStyle, "gapWidth").toInt() ulMarginLeft = parseFloat(ulStyle, "marginLeft").toInt() + ulMarginTop = parseFloat(ulStyle, "marginTop").toInt() + ulMarginBottom = parseFloat(ulStyle, "marginBottom").toInt() ulBulletSize = parseFloat(ulStyle, "bulletSize").toInt() val ulCheckboxStyle = style.getMap("ulCheckbox") ulCheckboxBoxSize = parseFloat(ulCheckboxStyle, "boxSize").toInt() ulCheckboxGapWidth = parseFloat(ulCheckboxStyle, "gapWidth").toInt() ulCheckboxMarginLeft = parseFloat(ulCheckboxStyle, "marginLeft").toInt() + ulCheckboxMarginTop = parseFloat(ulCheckboxStyle, "marginTop").toInt() + ulCheckboxMarginBottom = parseFloat(ulCheckboxStyle, "marginBottom").toInt() ulCheckboxBoxColor = parseColor(ulCheckboxStyle, "boxColor") val aStyle = style.getMap("a") diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt index 57f265b12..4a887bb29 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt @@ -8,6 +8,7 @@ import android.util.Log import android.view.MotionEvent import android.widget.TextView import com.swmansion.enriched.common.EnrichedSpanFlags +import com.swmansion.enriched.common.spans.EnrichedBlockSpacingSpan import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan import org.json.JSONObject @@ -57,8 +58,15 @@ fun TextView.setCheckboxClickListener() { val isFirstLine = spannable.getSpanStart(span) == lineStart val marginWidth = span.getLeadingMargin(true) + // Block margins are part of the line's height, but taps on them shouldn't toggle the checkbox + val blockSpacing = spannable.getSpans(lineStart, lineStart, EnrichedBlockSpacingSpan::class.java).firstOrNull() + val spacingAbove = if (blockSpacing != null && spannable.getSpanStart(blockSpacing) == lineStart) blockSpacing.spacingBefore else 0 + val spacingBelow = + if (blockSpacing != null && spannable.getSpanEnd(blockSpacing) <= layout.getLineEnd(line)) blockSpacing.spacingAfter else 0 + val isOnLineText = y in (layout.getLineTop(line) + spacingAbove)..(layout.getLineBottom(line) - spacingBelow) + // Check if touch is on checkbox icon area (which is in the leading margin on the first line) - val isInHotZone = isFirstLine && x in 0..marginWidth + val isInHotZone = isFirstLine && isOnLineText && x in 0..marginWidth when (event.action) { MotionEvent.ACTION_DOWN -> { diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 5718b75de..7963fc5ef 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -16,7 +16,7 @@ import { type OnMentionDetected, type OnLinkPressEvent, } from 'react-native-enriched-html'; -import { WEB_DEFAULT_HTML_STYLE } from './defaultHtmlStyle'; +import { WEB_DEV_HTML_STYLE } from './defaultHtmlStyle'; import type { NativeSyntheticEvent } from 'react-native'; import { EditorActions } from './components/EditorActions'; import { SetValueModal } from './components/SetValueModal'; @@ -287,7 +287,7 @@ function App() { onEndMention={handleEndMention} onMentionDetected={handleOnMentionDetected} mentionIndicators={['@', '#']} - htmlStyle={WEB_DEFAULT_HTML_STYLE} + htmlStyle={WEB_DEV_HTML_STYLE} linkRegex={LINK_REGEX} sanitizationConfig={SANITIZATION_CONFIG} /> diff --git a/apps/example-web/src/components/TextRenderer.tsx b/apps/example-web/src/components/TextRenderer.tsx index 3b7a7ce24..8816a66d3 100644 --- a/apps/example-web/src/components/TextRenderer.tsx +++ b/apps/example-web/src/components/TextRenderer.tsx @@ -9,7 +9,7 @@ import { type OnLinkPressEvent, type OnMentionPressEvent, } from 'react-native-enriched-html'; -import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; +import { WEB_DEV_HTML_STYLE } from '../defaultHtmlStyle'; import { EnrichedTextActions } from './EnrichedTextActions'; const LINK_REGEX = @@ -56,7 +56,7 @@ export function TextRenderer({ htmlValue }: TextRendererProps) { ; @@ -40,7 +40,7 @@ export const TextRenderer = ({ nodes }: TextRendererProps) => { ( + style: T +): T => ({ + ...style, + h1: { ...style.h1, ...blockMargins.h1 }, + h2: { ...style.h2, ...blockMargins.h2 }, + h3: { ...style.h3, ...blockMargins.h3 }, + h4: { ...style.h4, ...blockMargins.h4 }, + h5: { ...style.h5, ...blockMargins.h5 }, + h6: { ...style.h6, ...blockMargins.h6 }, + ol: { ...style.ol, ...blockMargins.ol }, + ul: { ...style.ul, ...blockMargins.ul }, + ulCheckbox: { ...style.ulCheckbox, ...blockMargins.ulCheckbox }, +}); + +export const devHtmlStyle = withBlockMargins(htmlStyle); + +export const devEnrichedTextHtmlStyle = withBlockMargins(enrichedTextHtmlStyle); diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index d5b4ba3f1..62d69adf4 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -10,7 +10,7 @@ import { ImageModal } from '../components/ImageModal'; import { useEditorState } from '../hooks/useEditorState'; import { LINK_REGEX, - htmlStyle, + devHtmlStyle, ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS, } from '../constants/editorConfig'; import { useState } from 'react'; @@ -45,7 +45,7 @@ export function DevScreen({ onSwitch }: DevScreenProps) { ref={editor.ref} mentionIndicators={['@', '#']} style={styles.editorInput} - htmlStyle={htmlStyle} + htmlStyle={devHtmlStyle} placeholder="Type something here..." placeholderTextColor="rgb(0, 26, 114)" selectionColor="deepskyblue" diff --git a/docs/docs/api-reference/enriched-text-input.md b/docs/docs/api-reference/enriched-text-input.md index 382e60a39..0ec84203b 100644 --- a/docs/docs/api-reference/enriched-text-input.md +++ b/docs/docs/api-reference/enriched-text-input.md @@ -1127,26 +1127,38 @@ interface HtmlStyle { h1?: { fontSize?: number; bold?: boolean; + marginTop?: number; + marginBottom?: number; }; h2?: { fontSize?: number; bold?: boolean; + marginTop?: number; + marginBottom?: number; }; h3?: { fontSize?: number; bold?: boolean; + marginTop?: number; + marginBottom?: number; }; h4?: { fontSize?: number; bold?: boolean; + marginTop?: number; + marginBottom?: number; }; h5?: { fontSize?: number; bold?: boolean; + marginTop?: number; + marginBottom?: number; }; h6?: { fontSize?: number; bold?: boolean; + marginTop?: number; + marginBottom?: number; }; blockquote?: { borderColor?: ColorValue; @@ -1171,6 +1183,8 @@ interface HtmlStyle { ol?: { gapWidth?: number; marginLeft?: number; + marginTop?: number; + marginBottom?: number; markerFontWeight?: TextStyle['fontWeight']; markerColor?: ColorValue; }; @@ -1178,12 +1192,16 @@ interface HtmlStyle { bulletColor?: ColorValue; bulletSize?: number; marginLeft?: number; + marginTop?: number; + marginBottom?: number; gapWidth?: number; }; ulCheckbox?: { boxColor?: ColorValue; boxSize?: number; marginLeft?: number; + marginTop?: number; + marginBottom?: number; gapWidth?: number; }; } @@ -1200,6 +1218,9 @@ interface MentionStyleProperties { - `fontSize` - size of the heading's font. Defaults to `32` for `H1`, `24` for `H2`, `20` for `H3`, `16` for `H4`, `14` for `H5`, `12` for `H6` - `bold` - whether the heading should be bolded; defaults to `false` +- `marginTop` - space above the heading; defaults to `0`. See + [vertical spacing](#vertical-spacing) +- `marginBottom` - space below the heading; defaults to `0` ### blockquote @@ -1270,6 +1291,9 @@ This way you can create a style for any mention indicator to fallback if it does By marker, we mean the number that denotes consecutive lines of the list. - `gapWidth` - gap between the marker and the list item's text; defaults to `16` +- `marginTop` - space above the list; defaults to `0`. See + [vertical spacing](#vertical-spacing) +- `marginBottom` - space below the list; defaults to `0` - `marginLeft` - margin to the left of the marker (between the marker and the input's left edge); defaults to `16` - `markerFontWeight` - font weight of the marker; takes a @@ -1288,6 +1312,9 @@ By bullet, we mean the dot that begins each line of the list. - `bulletSize` - height and width of the bullet; defaults to `8` - `marginLeft` - margin to the left of the bullet; defaults to `16` - `gapWidth` - gap between the bullet and the list item's text; defaults to `16` +- `marginTop` - space above the list; defaults to `0`. See + [vertical spacing](#vertical-spacing) +- `marginBottom` - space below the list; defaults to `0` ### ulCheckbox (checkbox list) @@ -1299,6 +1326,29 @@ Unordered list with checkboxes instead of bullets. - `marginLeft` - margin to the left of the checkbox; defaults to `16` - `gapWidth` - gap between the checkbox and the list item's text; defaults to `16` +- `marginTop` - space above the list; defaults to `0`. See + [vertical spacing](#vertical-spacing) +- `marginBottom` - space below the list; defaults to `0` + +### Vertical spacing + +`marginTop` and `marginBottom` add space around headings and lists, separately +from the line height: + +```tsx +htmlStyle={{ + h2: { fontSize: 20, bold: true, marginTop: 18, marginBottom: 8 }, + ul: { marginTop: 6, marginBottom: 6 }, +}} +``` + +- A list's margins go around the whole list, not around each item. +- Margins of adjacent blocks collapse into the larger one, as in CSS: a heading + with `marginBottom: 8` followed by a list with `marginTop: 6` is 8 apart. +- No space is added above the first block or below the last block. +- Margins must be `0` or greater. +- The spacing is visual only. It doesn't add characters, so the HTML, the text + and selection offsets stay the same. ## EnrichedInputStyle type diff --git a/docs/docs/core-functionalities/styling-the-input.mdx b/docs/docs/core-functionalities/styling-the-input.mdx index 26ba3c24a..84bf7176c 100644 --- a/docs/docs/core-functionalities/styling-the-input.mdx +++ b/docs/docs/core-functionalities/styling-the-input.mdx @@ -62,21 +62,36 @@ to their React Native `TextStyle` counterparts. Some are platform-limited `htmlStyle` maps each supported element to a small config object. Anything you omit falls back to the built-in default. The available keys are: -| Key | Styles | Notable options | -| ------------ | -------------- | ----------------------------------------------------------- | -| `h1`–`h6` | Headings | `fontSize`, `bold` | -| `blockquote` | Blockquote | `borderColor`, `borderWidth`, `gapWidth`, `color` | -| `codeblock` | Code block | `color`, `backgroundColor`, `borderRadius` | -| `code` | Inline code | `color`, `backgroundColor` | -| `a` | Links | `color`, `textDecorationLine` | -| `mention` | Mentions | `color`, `backgroundColor`, `textDecorationLine` | -| `ol` | Ordered list | `markerColor`, `markerFontWeight`, `marginLeft`, `gapWidth` | -| `ul` | Unordered list | `bulletColor`, `bulletSize`, `marginLeft`, `gapWidth` | -| `ulCheckbox` | Checkbox list | `boxColor`, `boxSize`, `marginLeft`, `gapWidth` | +| Key | Styles | Notable options | +| ------------ | -------------- | ---------------------------------------------------------------------------------------- | +| `h1`–`h6` | Headings | `fontSize`, `bold`, `marginTop`, `marginBottom` | +| `blockquote` | Blockquote | `borderColor`, `borderWidth`, `gapWidth`, `color` | +| `codeblock` | Code block | `color`, `backgroundColor`, `borderRadius` | +| `code` | Inline code | `color`, `backgroundColor` | +| `a` | Links | `color`, `textDecorationLine` | +| `mention` | Mentions | `color`, `backgroundColor`, `textDecorationLine` | +| `ol` | Ordered list | `markerColor`, `markerFontWeight`, `marginLeft`, `gapWidth`, `marginTop`, `marginBottom` | +| `ul` | Unordered list | `bulletColor`, `bulletSize`, `marginLeft`, `gapWidth`, `marginTop`, `marginBottom` | +| `ulCheckbox` | Checkbox list | `boxColor`, `boxSize`, `marginLeft`, `gapWidth`, `marginTop`, `marginBottom` | The full list of properties, defaults, and platform notes lives in the [`EnrichedTextInput`](/api-reference/enriched-text-input#htmlstyle) reference. +### Spacing around headings and lists + +`marginTop` and `marginBottom` add vertical space around headings and lists +without changing the line height. Margins of adjacent blocks collapse into the +larger one, and the first and last blocks get no outer space: + +```tsx +htmlStyle={{ + h1: { fontSize: 28, bold: true, marginTop: 24, marginBottom: 8 }, + h2: { fontSize: 22, bold: true, marginTop: 18, marginBottom: 8 }, + ul: { marginTop: 6, marginBottom: 6 }, + ol: { marginTop: 6, marginBottom: 6 }, +}} +``` + ### Styling mentions per indicator `mention` accepts either a single config applied to every mention, or a record diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c44d4d9dd..07476ebb1 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -11,6 +11,7 @@ #import "internals/EnrichedTextInputViewComponentDescriptor.h" #import "utils/AlignmentUtils.h" #import "utils/AttachmentLayoutUtils.h" +#import "utils/BlockSpacingUtils.h" #import "utils/DotReplacementUtils.h" #import "utils/KeyboardUtils.h" #import "utils/ParagraphAttributesUtils.h" @@ -580,6 +581,49 @@ - (void)updateProps:(Props::Shared const &)props } } + // block margins + const auto &newHtmlStyle = newViewProps.htmlStyle; + BOOL blockMarginsChanged = NO; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h1.marginTop + bottom:newHtmlStyle.h1.marginBottom + forStyle:H1]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h2.marginTop + bottom:newHtmlStyle.h2.marginBottom + forStyle:H2]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h3.marginTop + bottom:newHtmlStyle.h3.marginBottom + forStyle:H3]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h4.marginTop + bottom:newHtmlStyle.h4.marginBottom + forStyle:H4]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h5.marginTop + bottom:newHtmlStyle.h5.marginBottom + forStyle:H5]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h6.marginTop + bottom:newHtmlStyle.h6.marginBottom + forStyle:H6]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.ul.marginTop + bottom:newHtmlStyle.ul.marginBottom + forStyle:UnorderedList]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.ol.marginTop + bottom:newHtmlStyle.ol.marginBottom + forStyle:OrderedList]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.ulCheckbox.marginTop + bottom:newHtmlStyle.ulCheckbox.marginBottom + forStyle:CheckboxList]; + if (blockMarginsChanged) { + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.a.textDecorationLine != oldViewProps.htmlStyle.a.textDecorationLine) { NSString *objcString = @@ -1758,6 +1802,9 @@ - (void)anyTextMayHaveBeenModified { // all the visible (not meta) attributes handling in the ranges that could // have changed [attributesManager handleDirtyRangesStyling]; + // block spacing depends on the neighbouring paragraphs, so it is refreshed + // for the whole text + [BlockSpacingUtils applyBlockSpacingInHost:self]; // update height on each character change [self tryUpdatingHeight]; // update active styles as well diff --git a/ios/EnrichedTextView.mm b/ios/EnrichedTextView.mm index 991b180c6..9ea359dd7 100644 --- a/ios/EnrichedTextView.mm +++ b/ios/EnrichedTextView.mm @@ -12,6 +12,7 @@ #import "internals/EnrichedTextComponentDescriptor.h" #import "textHtmlParser/TextHtmlParser.h" #import "utils/AttachmentLayoutUtils.h" +#import "utils/BlockSpacingUtils.h" #import "utils/EnrichedTextTouchHandler.h" #import "utils/StyleUtils.h" #import @@ -516,6 +517,49 @@ - (void)updateProps:(Props::Shared const &)props } } + // block margins + const auto &newHtmlStyle = newViewProps.htmlStyle; + BOOL blockMarginsChanged = NO; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h1.marginTop + bottom:newHtmlStyle.h1.marginBottom + forStyle:H1]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h2.marginTop + bottom:newHtmlStyle.h2.marginBottom + forStyle:H2]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h3.marginTop + bottom:newHtmlStyle.h3.marginBottom + forStyle:H3]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h4.marginTop + bottom:newHtmlStyle.h4.marginBottom + forStyle:H4]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h5.marginTop + bottom:newHtmlStyle.h5.marginBottom + forStyle:H5]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.h6.marginTop + bottom:newHtmlStyle.h6.marginBottom + forStyle:H6]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.ul.marginTop + bottom:newHtmlStyle.ul.marginBottom + forStyle:UnorderedList]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.ol.marginTop + bottom:newHtmlStyle.ol.marginBottom + forStyle:OrderedList]; + blockMarginsChanged |= + [newConfig setBlockMarginTop:newHtmlStyle.ulCheckbox.marginTop + bottom:newHtmlStyle.ulCheckbox.marginBottom + forStyle:CheckboxList]; + if (blockMarginsChanged) { + stylePropChanged = YES; + } + // mention folly::dynamic oldMentionStyle = oldViewProps.htmlStyle.mention; folly::dynamic newMentionStyle = newViewProps.htmlStyle.mention; @@ -633,6 +677,7 @@ - (void)renderContent { return; } [_textParser replaceWholeFromHtml:_content]; + [BlockSpacingUtils applyBlockSpacingInHost:self]; [self layoutAttachments]; } diff --git a/ios/config/EnrichedConfig.h b/ios/config/EnrichedConfig.h index be7b8eb7b..763f17b24 100644 --- a/ios/config/EnrichedConfig.h +++ b/ios/config/EnrichedConfig.h @@ -1,6 +1,7 @@ #pragma once #import "interfaces/LinkRegexConfig.h" #import "interfaces/MentionStyleProps.h" +#import "interfaces/StyleTypeEnum.h" #import "interfaces/TextDecorationLineEnum.h" #import @@ -102,6 +103,14 @@ - (void)setCheckboxListBoxColor:(UIColor *)newValue; - (UIImage *)checkboxCheckedImage; - (UIImage *)checkboxUncheckedImage; +// Vertical space around heading and list blocks, keyed by their style type. +// The setter returns YES if the margins changed. +- (CGFloat)blockMarginTopForStyle:(StyleType)type; +- (CGFloat)blockMarginBottomForStyle:(StyleType)type; +- (BOOL)setBlockMarginTop:(CGFloat)top + bottom:(CGFloat)bottom + forStyle:(StyleType)type; +- (BOOL)hasBlockMargins; // MARK: - Input only props - (LinkRegexConfig *)linkRegexConfig; diff --git a/ios/config/EnrichedConfig.mm b/ios/config/EnrichedConfig.mm index 89f0e9d1d..fe1f81788 100644 --- a/ios/config/EnrichedConfig.mm +++ b/ios/config/EnrichedConfig.mm @@ -54,6 +54,7 @@ @implementation EnrichedConfig { UIColor *_checkboxListBoxColor; UIImage *_checkboxCheckedImage; UIImage *_checkboxUncheckedImage; + NSMutableDictionary *_blockMargins; // input only LinkRegexConfig *_linkRegexConfig; @@ -68,6 +69,7 @@ - (instancetype)init { _primaryFontNeedsRecreation = YES; _monospacedFontNeedsRecreation = YES; _olMarkerFontNeedsRecreation = YES; + _blockMargins = [[NSMutableDictionary alloc] init]; return self; } @@ -122,6 +124,7 @@ - (id)copyWithZone:(NSZone *)zone { copy->_checkboxListBoxColor = [_checkboxListBoxColor copy]; copy->_checkboxCheckedImage = _checkboxCheckedImage; copy->_checkboxUncheckedImage = _checkboxUncheckedImage; + copy->_blockMargins = [_blockMargins mutableCopy]; // input only copy->_linkRegexConfig = [_linkRegexConfig copy]; @@ -676,6 +679,46 @@ - (UIImage *)generateCheckboxImage:(BOOL)isChecked { return result; } +- (UIEdgeInsets)blockMarginsForStyle:(StyleType)type { + UIEdgeInsets margins = [_blockMargins[@(type)] UIEdgeInsetsValue]; + if (_allowFontScaling) { + UIFontMetrics *metrics = [UIFontMetrics defaultMetrics]; + margins.top = [metrics scaledValueForValue:margins.top]; + margins.bottom = [metrics scaledValueForValue:margins.bottom]; + } + return margins; +} + +- (CGFloat)blockMarginTopForStyle:(StyleType)type { + return [self blockMarginsForStyle:type].top; +} + +- (CGFloat)blockMarginBottomForStyle:(StyleType)type { + return [self blockMarginsForStyle:type].bottom; +} + +- (BOOL)setBlockMarginTop:(CGFloat)top + bottom:(CGFloat)bottom + forStyle:(StyleType)type { + UIEdgeInsets newMargins = UIEdgeInsetsMake(top, 0, bottom, 0); + if (UIEdgeInsetsEqualToEdgeInsets([_blockMargins[@(type)] UIEdgeInsetsValue], + newMargins)) { + return NO; + } + _blockMargins[@(type)] = [NSValue valueWithUIEdgeInsets:newMargins]; + return YES; +} + +- (BOOL)hasBlockMargins { + for (NSValue *value in _blockMargins.allValues) { + UIEdgeInsets margins = [value UIEdgeInsetsValue]; + if (margins.top > 0 || margins.bottom > 0) { + return YES; + } + } + return NO; +} + // MARK: - Input only props - (LinkRegexConfig *)linkRegexConfig { diff --git a/ios/utils/BlockSpacingUtils.h b/ios/utils/BlockSpacingUtils.h new file mode 100644 index 000000000..ff5611a93 --- /dev/null +++ b/ios/utils/BlockSpacingUtils.h @@ -0,0 +1,11 @@ +#pragma once +#import "interfaces/EnrichedViewHost.h" +#import + +// Applies the configured heading and list margins as paragraph spacing. +// Adjacent margins collapse into the larger one and the first and last blocks +// get no outer space. Only paragraph styles change, never the characters, so +// the HTML, the plain text and selection offsets stay the same. +@interface BlockSpacingUtils : NSObject ++ (void)applyBlockSpacingInHost:(id)host; +@end diff --git a/ios/utils/BlockSpacingUtils.mm b/ios/utils/BlockSpacingUtils.mm new file mode 100644 index 000000000..006aae868 --- /dev/null +++ b/ios/utils/BlockSpacingUtils.mm @@ -0,0 +1,145 @@ +#import "BlockSpacingUtils.h" +#import "interfaces/StyleHeaders.h" +#import "utils/TextListsUtils.h" +#import + +namespace { +struct BlockParagraph { + NSRange range; + StyleType type; + CGFloat marginTop; + CGFloat marginBottom; +}; + +struct BlockMarker { + NSString *prefix; + StyleType type; + CGFloat marginTop; + CGFloat marginBottom; +}; +} // namespace + +static const StyleType kSpacedBlockTypes[] = { + H1, H2, H3, H4, H5, H6, UnorderedList, OrderedList, CheckboxList}; + +// Consecutive items of one list form a single block, while every heading +// paragraph is a block of its own. +static BOOL isSameBlock(const BlockParagraph &previous, + const BlockParagraph &next) { + BOOL isList = previous.type == UnorderedList || + previous.type == OrderedList || previous.type == CheckboxList; + return isList && previous.type == next.type; +} + +@implementation BlockSpacingUtils + ++ (void)applyBlockSpacingInHost:(id)host { + // Without margins, spacing stays at 0, as every re-render starts from + // default paragraph styles. + if (![host.config hasBlockMargins]) { + return; + } + + std::vector paragraphs = [self paragraphsInHost:host]; + std::vector spacingBefore(paragraphs.size(), 0); + std::vector spacingAfter(paragraphs.size(), 0); + + for (size_t i = 0; i + 1 < paragraphs.size(); i++) { + const BlockParagraph &previous = paragraphs[i]; + const BlockParagraph &next = paragraphs[i + 1]; + if (isSameBlock(previous, next)) { + continue; + } + // The gap goes on the block whose margin it is, so that blockquote and + // code block decorations, drawn over whole line fragments, don't stretch + // into it. + if (next.marginTop >= previous.marginBottom) { + spacingBefore[i + 1] = next.marginTop; + } else { + spacingAfter[i] = previous.marginBottom; + } + } + + NSTextStorage *textStorage = host.textView.textStorage; + [textStorage beginEditing]; + for (size_t i = 0; i < paragraphs.size(); i++) { + NSRange range = paragraphs[i].range; + if (range.length == 0) { + continue; + } + CGFloat before = spacingBefore[i]; + CGFloat after = spacingAfter[i]; + [textStorage + enumerateAttribute:NSParagraphStyleAttributeName + inRange:range + options:0 + usingBlock:^(id _Nullable value, NSRange subRange, + BOOL *_Nonnull stop) { + NSParagraphStyle *pStyle = (NSParagraphStyle *)value; + if (pStyle.paragraphSpacingBefore == before && + pStyle.paragraphSpacing == after) { + return; + } + NSMutableParagraphStyle *newPStyle = + pStyle != nil ? [pStyle mutableCopy] + : [[NSMutableParagraphStyle alloc] init]; + newPStyle.paragraphSpacingBefore = before; + newPStyle.paragraphSpacing = after; + [textStorage addAttribute:NSParagraphStyleAttributeName + value:newPStyle + range:subRange]; + }]; + } + [textStorage endEditing]; +} + ++ (std::vector)paragraphsInHost:(id)host { + NSTextStorage *textStorage = host.textView.textStorage; + NSString *string = textStorage.string; + EnrichedConfig *config = host.config; + + std::vector markers; + for (StyleType type : kSpacedBlockTypes) { + StyleBase *style = (StyleBase *)host.stylesDict[@(type)]; + if (style != nil) { + markers.push_back({[style getMarkerPrefix] ?: [style getValue], + type, + [config blockMarginTopForStyle:type], + [config blockMarginBottomForStyle:type]}); + } + } + + std::vector paragraphs; + NSUInteger location = 0; + while (location < string.length) { + NSRange range = [string paragraphRangeForRange:NSMakeRange(location, 0)]; + NSParagraphStyle *pStyle = + [textStorage attribute:NSParagraphStyleAttributeName + atIndex:range.location + effectiveRange:nil]; + + BlockParagraph paragraph = {range, None, 0, 0}; + for (const BlockMarker &marker : markers) { + if ([TextListsUtils textLists:pStyle.textLists + containsPrefix:marker.prefix]) { + paragraph = {range, marker.type, marker.marginTop, marker.marginBottom}; + break; + } + } + + paragraphs.push_back(paragraph); + location = NSMaxRange(range); + } + + // A trailing newline leaves an empty last line, which is laid out as one + // more paragraph. + if (string.length > 0 && + [[NSCharacterSet newlineCharacterSet] + characterIsMember:[string characterAtIndex:string.length - 1]]) { + paragraphs.push_back({NSMakeRange(string.length, 0), None, 0, 0}); + } + + return paragraphs; +} + +@end diff --git a/ios/utils/CheckboxHitTestUtils.mm b/ios/utils/CheckboxHitTestUtils.mm index 78fe181a5..2da1ea639 100644 --- a/ios/utils/CheckboxHitTestUtils.mm +++ b/ios/utils/CheckboxHitTestUtils.mm @@ -62,8 +62,11 @@ + (CGRect)checkboxRectForGlyphIndex:(NSUInteger)glyphIndex return CGRectNull; } - CGRect lineRect = [layoutManager lineFragmentRectForGlyphAtIndex:glyphIndex - effectiveRange:nil]; + // The used rect leaves out paragraph spacing (block margins), which the + // line fragment rect includes. + CGRect lineRect = + [layoutManager lineFragmentUsedRectForGlyphAtIndex:glyphIndex + effectiveRange:nil]; CGFloat originX = lineRect.origin.x + config.checkboxListMarginLeft; diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index c5d2e61e3..43a118ff2 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -304,6 +304,8 @@ export interface OnPasteImagesEvent { type Heading = { fontSize?: CodegenTypes.Float; bold?: boolean; + marginTop?: CodegenTypes.Float; + marginBottom?: CodegenTypes.Float; }; export interface HtmlStyleInternal { @@ -338,6 +340,8 @@ export interface HtmlStyleInternal { ol?: { gapWidth?: CodegenTypes.Float; marginLeft?: CodegenTypes.Float; + marginTop?: CodegenTypes.Float; + marginBottom?: CodegenTypes.Float; markerFontWeight?: string; markerColor?: ColorValue; }; @@ -345,12 +349,16 @@ export interface HtmlStyleInternal { bulletColor?: ColorValue; bulletSize?: CodegenTypes.Float; marginLeft?: CodegenTypes.Float; + marginTop?: CodegenTypes.Float; + marginBottom?: CodegenTypes.Float; gapWidth?: CodegenTypes.Float; }; ulCheckbox?: { gapWidth?: CodegenTypes.Float; boxSize?: CodegenTypes.Float; marginLeft?: CodegenTypes.Float; + marginTop?: CodegenTypes.Float; + marginBottom?: CodegenTypes.Float; boxColor?: ColorValue; }; } diff --git a/src/spec/EnrichedTextNativeComponent.ts b/src/spec/EnrichedTextNativeComponent.ts index fcb54b471..2e4bfc5df 100644 --- a/src/spec/EnrichedTextNativeComponent.ts +++ b/src/spec/EnrichedTextNativeComponent.ts @@ -4,6 +4,8 @@ import type { CodegenTypes, HostComponent, ViewProps } from 'react-native'; type Heading = { fontSize?: CodegenTypes.Float; bold?: boolean; + marginTop?: CodegenTypes.Float; + marginBottom?: CodegenTypes.Float; }; export interface EnrichedTextHtmlStyleInternal { @@ -40,6 +42,8 @@ export interface EnrichedTextHtmlStyleInternal { ol?: { gapWidth?: CodegenTypes.Float; marginLeft?: CodegenTypes.Float; + marginTop?: CodegenTypes.Float; + marginBottom?: CodegenTypes.Float; markerFontWeight?: string; markerColor?: ColorValue; }; @@ -47,12 +51,16 @@ export interface EnrichedTextHtmlStyleInternal { bulletColor?: ColorValue; bulletSize?: CodegenTypes.Float; marginLeft?: CodegenTypes.Float; + marginTop?: CodegenTypes.Float; + marginBottom?: CodegenTypes.Float; gapWidth?: CodegenTypes.Float; }; ulCheckbox?: { gapWidth?: CodegenTypes.Float; boxSize?: CodegenTypes.Float; marginLeft?: CodegenTypes.Float; + marginTop?: CodegenTypes.Float; + marginBottom?: CodegenTypes.Float; boxColor?: ColorValue; }; } diff --git a/src/types.ts b/src/types.ts index d478b123c..d238db3fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -189,6 +189,8 @@ export interface EnrichedInputStyle { interface HeadingStyle { fontSize?: number; bold?: boolean; + marginTop?: number; + marginBottom?: number; } export interface MentionStyleProperties { @@ -229,6 +231,8 @@ export interface HtmlStyle { ol?: { gapWidth?: number; marginLeft?: number; + marginTop?: number; + marginBottom?: number; markerFontWeight?: TextStyle['fontWeight']; markerColor?: ColorValue; }; @@ -236,12 +240,16 @@ export interface HtmlStyle { bulletColor?: ColorValue; bulletSize?: number; marginLeft?: number; + marginTop?: number; + marginBottom?: number; gapWidth?: number; }; ulCheckbox?: { boxSize?: number; gapWidth?: number; marginLeft?: number; + marginTop?: number; + marginBottom?: number; boxColor?: ColorValue; }; } diff --git a/src/utils/defaultHtmlStyle.ts b/src/utils/defaultHtmlStyle.ts index 06666b7ad..b59a6228e 100644 --- a/src/utils/defaultHtmlStyle.ts +++ b/src/utils/defaultHtmlStyle.ts @@ -4,26 +4,38 @@ export const DEFAULT_HTML_STYLE: Required = { h1: { fontSize: 32, bold: false, + marginTop: 0, + marginBottom: 0, }, h2: { fontSize: 24, bold: false, + marginTop: 0, + marginBottom: 0, }, h3: { fontSize: 20, bold: false, + marginTop: 0, + marginBottom: 0, }, h4: { fontSize: 16, bold: false, + marginTop: 0, + marginBottom: 0, }, h5: { fontSize: 14, bold: false, + marginTop: 0, + marginBottom: 0, }, h6: { fontSize: 12, bold: false, + marginTop: 0, + marginBottom: 0, }, blockquote: { borderColor: 'darkgray', @@ -53,6 +65,8 @@ export const DEFAULT_HTML_STYLE: Required = { ol: { gapWidth: 16, marginLeft: 16, + marginTop: 0, + marginBottom: 0, markerFontWeight: undefined, markerColor: undefined, }, @@ -60,12 +74,16 @@ export const DEFAULT_HTML_STYLE: Required = { bulletColor: 'black', bulletSize: 8, marginLeft: 16, + marginTop: 0, + marginBottom: 0, gapWidth: 16, }, ulCheckbox: { boxSize: 24, gapWidth: 16, marginLeft: 16, + marginTop: 0, + marginBottom: 0, boxColor: 'blue', }, }; diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 32181dad2..f36d2468c 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -58,7 +58,7 @@ .et-view h1 { font-size: var(--et-h1-font-size); font-weight: var(--et-h1-font-weight); - margin: 0; + margin: var(--et-h1-margin-top, 0) 0 var(--et-h1-margin-bottom, 0); line-height: normal; color: inherit; } @@ -66,7 +66,7 @@ .et-view h2 { font-size: var(--et-h2-font-size); font-weight: var(--et-h2-font-weight); - margin: 0; + margin: var(--et-h2-margin-top, 0) 0 var(--et-h2-margin-bottom, 0); line-height: normal; color: inherit; } @@ -74,7 +74,7 @@ .et-view h3 { font-size: var(--et-h3-font-size); font-weight: var(--et-h3-font-weight); - margin: 0; + margin: var(--et-h3-margin-top, 0) 0 var(--et-h3-margin-bottom, 0); line-height: normal; color: inherit; } @@ -82,7 +82,7 @@ .et-view h4 { font-size: var(--et-h4-font-size); font-weight: var(--et-h4-font-weight); - margin: 0; + margin: var(--et-h4-margin-top, 0) 0 var(--et-h4-margin-bottom, 0); line-height: normal; color: inherit; } @@ -90,7 +90,7 @@ .et-view h5 { font-size: var(--et-h5-font-size); font-weight: var(--et-h5-font-weight); - margin: 0; + margin: var(--et-h5-margin-top, 0) 0 var(--et-h5-margin-bottom, 0); line-height: normal; color: inherit; } @@ -98,7 +98,7 @@ .et-view h6 { font-size: var(--et-h6-font-size); font-weight: var(--et-h6-font-weight); - margin: 0; + margin: var(--et-h6-margin-top, 0) 0 var(--et-h6-margin-bottom, 0); line-height: normal; color: inherit; } @@ -176,7 +176,7 @@ .eti-editor ul:not([data-type]), .et-view ul:not([data-type]) { - margin: 0; + margin: var(--et-ul-margin-top, 0) 0 var(--et-ul-margin-bottom, 0); list-style: none; padding-left: calc( var(--et-ul-margin-left, 16px) + @@ -210,7 +210,7 @@ .eti-editor ol, .et-view ol { - margin: 0; + margin: var(--et-ol-margin-top, 0) 0 var(--et-ol-margin-bottom, 0); list-style: none; /* marker column grows to fit the widest counter ("N."), floored at the configured margin. */ @@ -288,7 +288,7 @@ } .eti-editor ul[data-type='checkboxList'] { - margin: 0; + margin: var(--et-checkbox-margin-top, 0) 0 var(--et-checkbox-margin-bottom, 0); list-style: none; padding-left: calc( var(--et-checkbox-margin-left, 16px) + @@ -334,7 +334,7 @@ } .et-view ul[data-type="checkbox"] { - margin: 0; + margin: var(--et-checkbox-margin-top, 0) 0 var(--et-checkbox-margin-bottom, 0); list-style: none; padding-left: calc( var(--et-checkbox-margin-left, 16px) + @@ -391,3 +391,15 @@ -webkit-mask: var(--et-broken-image-glyph) no-repeat center / contain; mask: var(--et-broken-image-glyph) no-repeat center / contain; } + +/* Block margins add no space above the first block or below the last one, + matching iOS and Android. Kept last so it wins over the block rules above. */ +.eti-editor .tiptap > :is(h1, h2, h3, h4, h5, h6, ul, ol):first-child, +.et-view > :is(h1, h2, h3, h4, h5, h6, ul, ol):first-child { + margin-top: 0; +} + +.eti-editor .tiptap > :is(h1, h2, h3, h4, h5, h6, ul, ol):last-child, +.et-view > :is(h1, h2, h3, h4, h5, h6, ul, ol):last-child { + margin-bottom: 0; +} diff --git a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts index cd1ca231a..51ed8190d 100644 --- a/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts +++ b/src/web/styleConversion/__tests__/htmlStyleToCSSVariables.test.ts @@ -158,6 +158,13 @@ describe('htmlStyleToCSSVariables', () => { '--et-h2-font-weight': 'normal', }, ], + [ + { h3: { marginTop: 18, marginBottom: 8 } }, + { + '--et-h3-margin-top': '18px', + '--et-h3-margin-bottom': '8px', + }, + ], [{ h3: { fontSize: 18 } }, { '--et-h3-font-size': '18px' }], [{}, {}], ] as Array<[HtmlStyle, CSSProperties]>; @@ -224,6 +231,8 @@ describe('htmlStyleToCSSVariables', () => { bulletColor: '#ff0000', bulletSize: 12, marginLeft: 8, + marginTop: 6, + marginBottom: 2, gapWidth: 4, }, }) @@ -231,6 +240,8 @@ describe('htmlStyleToCSSVariables', () => { '--et-ul-bullet-color': '#ff0000', '--et-ul-bullet-size': '12px', '--et-ul-margin-left': '8px', + '--et-ul-margin-top': '6px', + '--et-ul-margin-bottom': '2px', '--et-ul-gap-width': '4px', }); }); @@ -241,6 +252,8 @@ describe('htmlStyleToCSSVariables', () => { ol: { gapWidth: 6, marginLeft: 10, + marginTop: 4, + marginBottom: 3, markerFontWeight: '700', markerColor: '#00ff00', }, @@ -248,6 +261,8 @@ describe('htmlStyleToCSSVariables', () => { ).toMatchObject({ '--et-ol-gap-width': '6px', '--et-ol-margin-left': '10px', + '--et-ol-margin-top': '4px', + '--et-ol-margin-bottom': '3px', '--et-ol-marker-font-weight': '700', '--et-ol-marker-color': '#00ff00', }); @@ -260,6 +275,8 @@ describe('htmlStyleToCSSVariables', () => { boxSize: 20, gapWidth: 8, marginLeft: 12, + marginTop: 5, + marginBottom: 7, boxColor: '#336699', }, }) @@ -267,6 +284,8 @@ describe('htmlStyleToCSSVariables', () => { '--et-checkbox-box-size': '20px', '--et-checkbox-gap-width': '8px', '--et-checkbox-margin-left': '12px', + '--et-checkbox-margin-top': '5px', + '--et-checkbox-margin-bottom': '7px', '--et-checkbox-box-color': '#336699', }); }); diff --git a/src/web/styleConversion/htmlStyleToCSSVariables.ts b/src/web/styleConversion/htmlStyleToCSSVariables.ts index c25a4859a..7dd292208 100644 --- a/src/web/styleConversion/htmlStyleToCSSVariables.ts +++ b/src/web/styleConversion/htmlStyleToCSSVariables.ts @@ -99,14 +99,20 @@ const ET_CSS_VARS = { ulBulletColor: '--et-ul-bullet-color', ulBulletSize: '--et-ul-bullet-size', ulMarginLeft: '--et-ul-margin-left', + ulMarginTop: '--et-ul-margin-top', + ulMarginBottom: '--et-ul-margin-bottom', ulGapWidth: '--et-ul-gap-width', olMarginLeft: '--et-ol-margin-left', + olMarginTop: '--et-ol-margin-top', + olMarginBottom: '--et-ol-margin-bottom', olGapWidth: '--et-ol-gap-width', olMarkerColor: '--et-ol-marker-color', olMarkerFontWeight: '--et-ol-marker-font-weight', checkboxBoxSize: '--et-checkbox-box-size', checkboxGapWidth: '--et-checkbox-gap-width', checkboxMarginLeft: '--et-checkbox-margin-left', + checkboxMarginTop: '--et-checkbox-margin-top', + checkboxMarginBottom: '--et-checkbox-margin-bottom', checkboxBoxColor: '--et-checkbox-box-color', } as const; @@ -154,6 +160,8 @@ function applyHeadingVars( vars[`--et-${level}-font-size`] = `${h.fontSize}px`; if (h?.bold != null) vars[`--et-${level}-font-weight`] = h.bold ? 'bold' : 'normal'; + setPxVar(vars, `--et-${level}-margin-top`, h?.marginTop); + setPxVar(vars, `--et-${level}-margin-bottom`, h?.marginBottom); } } @@ -194,6 +202,8 @@ function applyUnorderedListVars( setColorVar(vars, ET_CSS_VARS.ulBulletColor, ul?.bulletColor); setPxVar(vars, ET_CSS_VARS.ulBulletSize, ul?.bulletSize); setPxVar(vars, ET_CSS_VARS.ulMarginLeft, ul?.marginLeft); + setPxVar(vars, ET_CSS_VARS.ulMarginTop, ul?.marginTop); + setPxVar(vars, ET_CSS_VARS.ulMarginBottom, ul?.marginBottom); setPxVar(vars, ET_CSS_VARS.ulGapWidth, ul?.gapWidth); } @@ -202,6 +212,8 @@ function applyOrderedListVars( ol?: HtmlStyle['ol'] ): void { setPxVar(vars, ET_CSS_VARS.olMarginLeft, ol?.marginLeft); + setPxVar(vars, ET_CSS_VARS.olMarginTop, ol?.marginTop); + setPxVar(vars, ET_CSS_VARS.olMarginBottom, ol?.marginBottom); setPxVar(vars, ET_CSS_VARS.olGapWidth, ol?.gapWidth); setColorVar(vars, ET_CSS_VARS.olMarkerColor, ol?.markerColor); if (ol?.markerFontWeight != null) { @@ -216,6 +228,8 @@ function applyCheckboxListVars( setPxVar(vars, ET_CSS_VARS.checkboxBoxSize, ulCheckbox?.boxSize); setPxVar(vars, ET_CSS_VARS.checkboxGapWidth, ulCheckbox?.gapWidth); setPxVar(vars, ET_CSS_VARS.checkboxMarginLeft, ulCheckbox?.marginLeft); + setPxVar(vars, ET_CSS_VARS.checkboxMarginTop, ulCheckbox?.marginTop); + setPxVar(vars, ET_CSS_VARS.checkboxMarginBottom, ulCheckbox?.marginBottom); setColorVar(vars, ET_CSS_VARS.checkboxBoxColor, ulCheckbox?.boxColor); }