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); }