Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Spacing> {
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<Paragraph> {
val bounds = mutableListOf<Pair<Int, Int>>()
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<Block>(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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -24,19 +36,25 @@ 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

// Checkbox list
val ulCheckboxBoxSize: Int
val ulCheckboxGapWidth: Int
val ulCheckboxMarginLeft: Int
val ulCheckboxMarginTop: Int
val ulCheckboxMarginBottom: Int
val ulCheckboxBoxColor: Int

// Links
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading