mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4307 from vitorpamplona/claude/cool-einstein-mkv9i8
Drop blank lines after block-level rich text segments
This commit is contained in:
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
/**
|
||||
* True when [segment] renders as a full-width block (a link-preview card, an image, a video,
|
||||
* a PDF) instead of an inline word. Media always draws as a block; a [LinkSegment] only does
|
||||
* when previews are on, otherwise it is an inline clickable URL.
|
||||
*/
|
||||
fun rendersAsBlock(
|
||||
segment: Segment,
|
||||
canPreview: Boolean,
|
||||
): Boolean =
|
||||
when (segment) {
|
||||
is ImageSegment, is VideoSegment, is PdfSegment, is Base64Segment -> true
|
||||
is LinkSegment -> canPreview
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** A paragraph that renders as an empty line: no words, or only blank text. */
|
||||
fun ParagraphState.isBlankLine(): Boolean = words.all { it is RegularTextSegment && it.segmentText.isBlank() }
|
||||
|
||||
/**
|
||||
* Drops the one blank line that directly follows a paragraph ending in a block (see
|
||||
* [rendersAsBlock]). The block already ends its row, so the `\n\n` the author typed to separate
|
||||
* the URL from the next sentence would otherwise render as an extra empty text line under the
|
||||
* card, doubling the gap. Standalone image paragraphs get the same treatment in [GalleryParser].
|
||||
*
|
||||
* Returns [paragraphs] itself when nothing is dropped.
|
||||
*/
|
||||
fun dropBlankLineAfterBlocks(
|
||||
paragraphs: List<ParagraphState>,
|
||||
canPreview: Boolean,
|
||||
): List<ParagraphState> {
|
||||
var result: ArrayList<ParagraphState>? = null
|
||||
|
||||
for (i in paragraphs.indices) {
|
||||
val paragraph = paragraphs[i]
|
||||
val previous = paragraphs.getOrNull(i - 1)
|
||||
val drop =
|
||||
previous != null &&
|
||||
paragraph.isBlankLine() &&
|
||||
!previous.isBlankLine() &&
|
||||
rendersAsBlock(previous.words.last(), canPreview)
|
||||
|
||||
if (drop) {
|
||||
if (result == null) result = ArrayList<ParagraphState>(paragraphs.size).apply { addAll(paragraphs.subList(0, i)) }
|
||||
} else {
|
||||
result?.add(paragraph)
|
||||
}
|
||||
}
|
||||
|
||||
return result ?: paragraphs
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BlockParagraphSpacingTest {
|
||||
private fun parse(text: String) = RichTextParser().parseText(text, EmptyTagList, null).paragraphs
|
||||
|
||||
@Test
|
||||
fun blankLineAfterTrailingLinkPreviewIsDropped() {
|
||||
val paragraphs = parse("See if you can restore your follow list here: https://metadata.nostr.com/#\n\nSomething zeroed it out.")
|
||||
|
||||
// The parser keeps the author's blank line as its own paragraph...
|
||||
assertEquals(3, paragraphs.size)
|
||||
assertTrue(paragraphs[0].words.last() is LinkSegment)
|
||||
assertTrue(paragraphs[1].isBlankLine())
|
||||
|
||||
// ...which the renderer drops under the card.
|
||||
val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = true)
|
||||
assertEquals(2, rendered.size)
|
||||
assertSame(paragraphs[0], rendered[0])
|
||||
assertSame(paragraphs[2], rendered[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankLineAfterInlineLinkIsKeptWithoutPreviews() {
|
||||
val paragraphs = parse("here: https://metadata.nostr.com/\n\nSomething zeroed it out.")
|
||||
|
||||
assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankLineAfterTrailingImageIsDropped() {
|
||||
val paragraphs = parse("look at this https://example.com/cat.jpg\n\nnice, right?")
|
||||
|
||||
assertEquals(3, paragraphs.size)
|
||||
val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = false)
|
||||
assertEquals(listOf(paragraphs[0], paragraphs[2]), rendered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyOneBlankLineIsDropped() {
|
||||
val paragraphs = parse("here: https://metadata.nostr.com/\n\n\nSomething zeroed it out.")
|
||||
|
||||
assertEquals(4, paragraphs.size)
|
||||
val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = true)
|
||||
assertEquals(listOf(paragraphs[0], paragraphs[2], paragraphs[3]), rendered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun linkInTheMiddleOfALineKeepsTheBlankLine() {
|
||||
val paragraphs = parse("here: https://metadata.nostr.com/ is the site\n\nSomething zeroed it out.")
|
||||
|
||||
assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankLineBeforeAPreviewIsKept() {
|
||||
val paragraphs = parse("Something zeroed it out.\n\nhttps://metadata.nostr.com/")
|
||||
|
||||
assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = true))
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Segment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.VideoSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.dropBlankLineAfterBlocks
|
||||
|
||||
/**
|
||||
* Cross-platform rich-text renderer. Owns everything identical on every front
|
||||
@@ -104,9 +105,10 @@ fun RichTextViewer(
|
||||
val renderer = LocalRichTextSegmentRenderer.current
|
||||
val baseStyle = LocalTextStyle.current
|
||||
val paragraphStyle = remember(baseStyle) { baseStyle.copy(lineHeight = 1.3.em) }
|
||||
val paragraphs = remember(state, canPreview) { dropBlankLineAfterBlocks(state.paragraphs, canPreview) }
|
||||
|
||||
Column(modifier) {
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
paragraphs.forEach { paragraph ->
|
||||
val align = if (paragraph.isRTL) Alignment.End else Alignment.Start
|
||||
if (paragraph is ImageGalleryParagraph) {
|
||||
renderer.Gallery(paragraph, state, Modifier.align(align))
|
||||
|
||||
Reference in New Issue
Block a user