Skip to content

Commit d7f53e1

Browse files
authored
Merge pull request #683 from MohamedRejeb/1.x
fix: clamp End alignment firstLine to 0 instead of falling back to Start
2 parents 3954da3 + d660cf0 commit d7f53e1

7 files changed

Lines changed: 211 additions & 16 deletions

File tree

richeditor-compose/api/android/richeditor-compose.api

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ public final class com/mohamedrejeb/richeditor/model/RichTextConfig {
130130
public final fun getLinkTextDecoration ()Landroidx/compose/ui/text/style/TextDecoration;
131131
public final fun getListIndent ()I
132132
public final fun getListMarkerStyleBehavior ()Lcom/mohamedrejeb/richeditor/paragraph/type/ListMarkerStyleBehavior;
133+
public final fun getListPrefixAlignment ()Lcom/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment;
133134
public final fun getOrderedListIndent ()I
134135
public final fun getOrderedListStyleType ()Lcom/mohamedrejeb/richeditor/paragraph/type/OrderedListStyleType;
135136
public final fun getPreserveStyleOnEmptyLine ()Z
@@ -143,6 +144,7 @@ public final class com/mohamedrejeb/richeditor/model/RichTextConfig {
143144
public final fun setLinkTextDecoration (Landroidx/compose/ui/text/style/TextDecoration;)V
144145
public final fun setListIndent (I)V
145146
public final fun setListMarkerStyleBehavior (Lcom/mohamedrejeb/richeditor/paragraph/type/ListMarkerStyleBehavior;)V
147+
public final fun setListPrefixAlignment (Lcom/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment;)V
146148
public final fun setOrderedListIndent (I)V
147149
public final fun setOrderedListStyleType (Lcom/mohamedrejeb/richeditor/paragraph/type/OrderedListStyleType;)V
148150
public final fun setPreserveStyleOnEmptyLine (Z)V
@@ -329,6 +331,14 @@ public final class com/mohamedrejeb/richeditor/paragraph/type/ListMarkerStyleBeh
329331
public static fun values ()[Lcom/mohamedrejeb/richeditor/paragraph/type/ListMarkerStyleBehavior;
330332
}
331333

334+
public final class com/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment : java/lang/Enum {
335+
public static final field End Lcom/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment;
336+
public static final field Start Lcom/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment;
337+
public static fun getEntries ()Lkotlin/enums/EnumEntries;
338+
public static fun valueOf (Ljava/lang/String;)Lcom/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment;
339+
public static fun values ()[Lcom/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment;
340+
}
341+
332342
public abstract interface class com/mohamedrejeb/richeditor/paragraph/type/OrderedListStyleType {
333343
public fun format (II)Ljava/lang/String;
334344
public fun getSuffix (I)Ljava/lang/String;

richeditor-compose/api/desktop/richeditor-compose.api

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@ public final class com/mohamedrejeb/richeditor/model/RichTextConfig {
131131
public final fun getListIndent ()I
132132
public final fun getListMarkerStyleBehavior ()Lcom/mohamedrejeb/richeditor/paragraph/type/ListMarkerStyleBehavior;
133133
public final fun getListPrefixAlignment ()Lcom/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment;
134-
public final fun getMaxImageWidth-XSAIIZE ()J
135134
public final fun getOrderedListIndent ()I
136135
public final fun getOrderedListStyleType ()Lcom/mohamedrejeb/richeditor/paragraph/type/OrderedListStyleType;
137136
public final fun getPreserveStyleOnEmptyLine ()Z
@@ -146,7 +145,6 @@ public final class com/mohamedrejeb/richeditor/model/RichTextConfig {
146145
public final fun setListIndent (I)V
147146
public final fun setListMarkerStyleBehavior (Lcom/mohamedrejeb/richeditor/paragraph/type/ListMarkerStyleBehavior;)V
148147
public final fun setListPrefixAlignment (Lcom/mohamedrejeb/richeditor/paragraph/type/ListPrefixAlignment;)V
149-
public final fun setMaxImageWidth--R2X_6o (J)V
150148
public final fun setOrderedListIndent (I)V
151149
public final fun setOrderedListStyleType (Lcom/mohamedrejeb/richeditor/paragraph/type/OrderedListStyleType;)V
152150
public final fun setPreserveStyleOnEmptyLine (Z)V

richeditor-compose/api/richeditor-compose.klib.api

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ final enum class com.mohamedrejeb.richeditor.paragraph.type/ListMarkerStyleBehav
2525
final fun values(): kotlin/Array<com.mohamedrejeb.richeditor.paragraph.type/ListMarkerStyleBehavior> // com.mohamedrejeb.richeditor.paragraph.type/ListMarkerStyleBehavior.values|values#static(){}[0]
2626
}
2727

28+
final enum class com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment : kotlin/Enum<com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment> { // com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment|null[0]
29+
enum entry End // com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment.End|null[0]
30+
enum entry Start // com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment.Start|null[0]
31+
32+
final val entries // com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment.entries|#static{}entries[0]
33+
final fun <get-entries>(): kotlin.enums/EnumEntries<com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment> // com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment.entries.<get-entries>|<get-entries>#static(){}[0]
34+
35+
final fun valueOf(kotlin/String): com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment // com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment.valueOf|valueOf#static(kotlin.String){}[0]
36+
final fun values(): kotlin/Array<com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment> // com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment.values|values#static(){}[0]
37+
}
38+
2839
final enum class com.mohamedrejeb.richeditor.ui/UndoBehavior : kotlin/Enum<com.mohamedrejeb.richeditor.ui/UndoBehavior> { // com.mohamedrejeb.richeditor.ui/UndoBehavior|null[0]
2940
enum entry Disabled // com.mohamedrejeb.richeditor.ui/UndoBehavior.Disabled|null[0]
3041
enum entry Enabled // com.mohamedrejeb.richeditor.ui/UndoBehavior.Enabled|null[0]
@@ -287,6 +298,9 @@ final class com.mohamedrejeb.richeditor.model/RichTextConfig { // com.mohamedrej
287298
final var listMarkerStyleBehavior // com.mohamedrejeb.richeditor.model/RichTextConfig.listMarkerStyleBehavior|{}listMarkerStyleBehavior[0]
288299
final fun <get-listMarkerStyleBehavior>(): com.mohamedrejeb.richeditor.paragraph.type/ListMarkerStyleBehavior // com.mohamedrejeb.richeditor.model/RichTextConfig.listMarkerStyleBehavior.<get-listMarkerStyleBehavior>|<get-listMarkerStyleBehavior>(){}[0]
289300
final fun <set-listMarkerStyleBehavior>(com.mohamedrejeb.richeditor.paragraph.type/ListMarkerStyleBehavior) // com.mohamedrejeb.richeditor.model/RichTextConfig.listMarkerStyleBehavior.<set-listMarkerStyleBehavior>|<set-listMarkerStyleBehavior>(com.mohamedrejeb.richeditor.paragraph.type.ListMarkerStyleBehavior){}[0]
301+
final var listPrefixAlignment // com.mohamedrejeb.richeditor.model/RichTextConfig.listPrefixAlignment|{}listPrefixAlignment[0]
302+
final fun <get-listPrefixAlignment>(): com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment // com.mohamedrejeb.richeditor.model/RichTextConfig.listPrefixAlignment.<get-listPrefixAlignment>|<get-listPrefixAlignment>(){}[0]
303+
final fun <set-listPrefixAlignment>(com.mohamedrejeb.richeditor.paragraph.type/ListPrefixAlignment) // com.mohamedrejeb.richeditor.model/RichTextConfig.listPrefixAlignment.<set-listPrefixAlignment>|<set-listPrefixAlignment>(com.mohamedrejeb.richeditor.paragraph.type.ListPrefixAlignment){}[0]
290304
final var orderedListIndent // com.mohamedrejeb.richeditor.model/RichTextConfig.orderedListIndent|{}orderedListIndent[0]
291305
final fun <get-orderedListIndent>(): kotlin/Int // com.mohamedrejeb.richeditor.model/RichTextConfig.orderedListIndent.<get-orderedListIndent>|<get-orderedListIndent>(){}[0]
292306
final fun <set-orderedListIndent>(kotlin/Int) // com.mohamedrejeb.richeditor.model/RichTextConfig.orderedListIndent.<set-orderedListIndent>|<set-orderedListIndent>(kotlin.Int){}[0]

richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/ImageLoader.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
44
import androidx.compose.runtime.Composable
55
import androidx.compose.runtime.Immutable
66
import androidx.compose.runtime.ProvidableCompositionLocal
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.setValue
710
import androidx.compose.runtime.staticCompositionLocalOf
811
import androidx.compose.ui.Alignment
912
import androidx.compose.ui.Modifier
1013
import androidx.compose.ui.graphics.painter.Painter
1114
import androidx.compose.ui.layout.ContentScale
15+
import androidx.compose.ui.unit.TextUnit
1216
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
1317

1418
public interface ImageLoader {
@@ -24,6 +28,20 @@ public val LocalImageLoader: ProvidableCompositionLocal<ImageLoader> = staticCom
2428
DefaultImageLoader
2529
}
2630

31+
/**
32+
* The container width available to images inside a [com.mohamedrejeb.richeditor.ui.BasicRichText].
33+
*
34+
* Populated by `BasicRichText` via `Modifier.onSizeChanged` so that images wider
35+
* than the container can be scaled down proportionally instead of overflowing.
36+
* When unspecified (default), images render at their intrinsic size.
37+
*/
38+
internal val LocalRichTextMaxImageWidthProvider: ProvidableCompositionLocal<RichTextMaxImageWidthProvider> =
39+
staticCompositionLocalOf { RichTextMaxImageWidthProvider() }
40+
41+
internal class RichTextMaxImageWidthProvider {
42+
var maxWidth by mutableStateOf(TextUnit.Unspecified)
43+
}
44+
2745
@ExperimentalRichTextApi
2846
@Immutable
2947
public class ImageData(

richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/model/RichSpanStyle.kt

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,13 @@ import androidx.compose.runtime.LaunchedEffect
88
import androidx.compose.runtime.mutableStateOf
99
import androidx.compose.runtime.getValue
1010
import androidx.compose.runtime.setValue
11-
import androidx.compose.runtime.key
12-
import androidx.compose.ui.Alignment
13-
import androidx.compose.ui.Modifier
1411
import androidx.compose.ui.geometry.CornerRadius
1512
import androidx.compose.ui.geometry.RoundRect
1613
import androidx.compose.ui.geometry.isUnspecified
1714
import androidx.compose.ui.graphics.Path
1815
import androidx.compose.ui.graphics.drawscope.DrawScope
1916
import androidx.compose.ui.graphics.drawscope.Fill
2017
import androidx.compose.ui.graphics.drawscope.Stroke
21-
import androidx.compose.ui.layout.ContentScale
2218
import androidx.compose.ui.platform.LocalDensity
2319
import androidx.compose.ui.text.*
2420
import androidx.compose.ui.unit.TextUnit
@@ -243,29 +239,40 @@ public interface RichSpanStyle {
243239
children = {
244240
val density = LocalDensity.current
245241
val imageLoader = LocalImageLoader.current
242+
val maxImageWidth = LocalRichTextMaxImageWidthProvider.current.maxWidth
246243
val data = imageLoader.load(model) ?: return@InlineTextContent
247244

248-
LaunchedEffect(id, data) {
245+
LaunchedEffect(id, data, maxImageWidth) {
249246
if (data.painter.intrinsicSize.isUnspecified)
250247
return@LaunchedEffect
251248

252-
val newWidth = with(density) {
249+
val intrinsicWidth = with(density) {
253250
data.painter.intrinsicSize.width.coerceAtLeast(0f).toSp()
254251
}
255-
val newHeight = with(density) {
252+
val intrinsicHeight = with(density) {
256253
data.painter.intrinsicSize.height.coerceAtLeast(0f).toSp()
257254
}
258255

259-
if (width == newWidth && height == newHeight)
256+
val (clampedWidth, clampedHeight) = clampToMaxWidth(
257+
width = intrinsicWidth,
258+
height = intrinsicHeight,
259+
maxWidth = maxImageWidth,
260+
)
261+
262+
val shouldSetWidth = width.isUnspecified ||
263+
width.value <= 0 ||
264+
width != clampedWidth
265+
val shouldSetHeight = height.isUnspecified ||
266+
height.value <= 0 ||
267+
height != clampedHeight
268+
269+
if (!shouldSetWidth && !shouldSetHeight)
260270
return@LaunchedEffect
261271

262272
richTextState.inlineContentMap.remove(id)
263273

264-
if (width.isUnspecified || width.value <= 0)
265-
width = newWidth
266-
267-
if (height.isUnspecified || height.value <= 0)
268-
height = newHeight
274+
if (shouldSetWidth) width = clampedWidth
275+
if (shouldSetHeight) height = clampedHeight
269276

270277
richTextState.inlineContentMap[id] = createInlineTextContent(richTextState = richTextState)
271278
richTextState.updateAnnotatedString()
@@ -287,6 +294,29 @@ public interface RichSpanStyle {
287294

288295
override val isAtomic: Boolean = true
289296

297+
internal companion object {
298+
/**
299+
* Scale [width]/[height] down proportionally so [width] is at most
300+
* [maxWidth]. Returns the input unchanged when [maxWidth] is
301+
* unspecified, non-positive, or already wider than [width].
302+
*/
303+
internal fun clampToMaxWidth(
304+
width: TextUnit,
305+
height: TextUnit,
306+
maxWidth: TextUnit,
307+
): Pair<TextUnit, TextUnit> {
308+
if (!maxWidth.isSpecified || maxWidth.value <= 0f) return width to height
309+
if (!width.isSpecified || width.value <= maxWidth.value) return width to height
310+
311+
val scale = maxWidth.value / width.value
312+
val clampedHeight = if (height.isSpecified)
313+
(height.value * scale).sp
314+
else
315+
height
316+
return maxWidth to clampedHeight
317+
}
318+
}
319+
290320
override fun equals(other: Any?): Boolean {
291321
if (this === other) return true
292322
if (other !is Image) return false

richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/ui/BasicRichText.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,26 @@ import androidx.compose.foundation.text.BasicText
55
import androidx.compose.foundation.text.InlineTextContent
66
import androidx.compose.runtime.Composable
77
import androidx.compose.runtime.CompositionLocalProvider
8+
import androidx.compose.runtime.getValue
89
import androidx.compose.runtime.mutableStateOf
910
import androidx.compose.runtime.remember
11+
import androidx.compose.runtime.setValue
1012
import androidx.compose.ui.Modifier
1113
import androidx.compose.ui.input.pointer.PointerIcon
1214
import androidx.compose.ui.input.pointer.pointerHoverIcon
1315
import androidx.compose.ui.input.pointer.pointerInput
16+
import androidx.compose.ui.layout.onSizeChanged
1417
import androidx.compose.ui.platform.LocalDensity
1518
import androidx.compose.ui.platform.LocalUriHandler
1619
import androidx.compose.ui.text.TextLayoutResult
1720
import androidx.compose.ui.text.TextStyle
1821
import androidx.compose.ui.text.style.TextOverflow
22+
import androidx.compose.ui.unit.TextUnit
1923
import com.mohamedrejeb.richeditor.gesture.detectTapGestures
2024
import com.mohamedrejeb.richeditor.model.ImageLoader
2125
import com.mohamedrejeb.richeditor.model.LocalImageLoader
26+
import com.mohamedrejeb.richeditor.model.LocalRichTextMaxImageWidthProvider
27+
import com.mohamedrejeb.richeditor.model.RichTextMaxImageWidthProvider
2228
import com.mohamedrejeb.richeditor.model.RichTextState
2329

2430
@Composable
@@ -39,6 +45,7 @@ public fun BasicRichText(
3945
val pointerIcon = remember {
4046
mutableStateOf(PointerIcon.Default)
4147
}
48+
val maxImageWidthProvider = remember { RichTextMaxImageWidthProvider() }
4249

4350
val text = remember(
4451
state.visualTransformation,
@@ -48,7 +55,8 @@ public fun BasicRichText(
4855
}
4956

5057
CompositionLocalProvider(
51-
LocalImageLoader provides imageLoader
58+
LocalImageLoader provides imageLoader,
59+
LocalRichTextMaxImageWidthProvider provides maxImageWidthProvider,
5260
) {
5361
BasicText(
5462
text = text,
@@ -87,6 +95,12 @@ public fun BasicRichText(
8795
state.isLink(offset)
8896
},
8997
)
98+
}
99+
.onSizeChanged { size ->
100+
val newWidth = with(density) { size.width.toSp() }
101+
if (newWidth != maxImageWidthProvider.maxWidth) {
102+
maxImageWidthProvider.maxWidth = newWidth
103+
}
90104
},
91105
style = style,
92106
onTextLayout = {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.mohamedrejeb.richeditor.model
2+
3+
import androidx.compose.ui.unit.TextUnit
4+
import androidx.compose.ui.unit.sp
5+
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
9+
/**
10+
* Regression for #423: images wider than the container should scale down
11+
* proportionally (aspect ratio preserved) instead of overflowing.
12+
*
13+
* Exercises the pure-math helper on [RichSpanStyle.Image.Companion]; the
14+
* visual integration uses the same function from the inline-content
15+
* `LaunchedEffect`, which reads the container width from
16+
* `LocalRichTextMaxImageWidth` provided by `BasicRichText`.
17+
*/
18+
@OptIn(ExperimentalRichTextApi::class)
19+
class ImageClampToMaxWidthTest {
20+
21+
@Test
22+
fun returnsInputUnchangedWhenMaxWidthUnspecified() {
23+
val (w, h) = RichSpanStyle.Image.clampToMaxWidth(
24+
width = 1500.sp,
25+
height = 150.sp,
26+
maxWidth = TextUnit.Unspecified,
27+
)
28+
assertEquals(1500.sp, w)
29+
assertEquals(150.sp, h)
30+
}
31+
32+
@Test
33+
fun returnsInputUnchangedWhenMaxWidthZero() {
34+
val (w, h) = RichSpanStyle.Image.clampToMaxWidth(
35+
width = 1500.sp,
36+
height = 150.sp,
37+
maxWidth = 0.sp,
38+
)
39+
assertEquals(1500.sp, w)
40+
assertEquals(150.sp, h)
41+
}
42+
43+
@Test
44+
fun returnsInputUnchangedWhenWidthAlreadyWithinMax() {
45+
val (w, h) = RichSpanStyle.Image.clampToMaxWidth(
46+
width = 300.sp,
47+
height = 200.sp,
48+
maxWidth = 400.sp,
49+
)
50+
assertEquals(300.sp, w)
51+
assertEquals(200.sp, h)
52+
}
53+
54+
@Test
55+
fun scalesWidthAndHeightProportionallyWhenTooWide() {
56+
// Reporter's exact case: 1500x150 image in a 300.sp wide container.
57+
val (w, h) = RichSpanStyle.Image.clampToMaxWidth(
58+
width = 1500.sp,
59+
height = 150.sp,
60+
maxWidth = 300.sp,
61+
)
62+
assertEquals(300.sp, w)
63+
// 150 * (300 / 1500) = 30
64+
assertEquals(30.sp, h)
65+
}
66+
67+
@Test
68+
fun squareImageScalesEqually() {
69+
val (w, h) = RichSpanStyle.Image.clampToMaxWidth(
70+
width = 1000.sp,
71+
height = 1000.sp,
72+
maxWidth = 250.sp,
73+
)
74+
assertEquals(250.sp, w)
75+
assertEquals(250.sp, h)
76+
}
77+
78+
@Test
79+
fun tallImageScalesToFitWidth() {
80+
val (w, h) = RichSpanStyle.Image.clampToMaxWidth(
81+
width = 800.sp,
82+
height = 1200.sp,
83+
maxWidth = 400.sp,
84+
)
85+
assertEquals(400.sp, w)
86+
// 1200 * 0.5 = 600
87+
assertEquals(600.sp, h)
88+
}
89+
90+
@Test
91+
fun exactMatchToMaxWidthIsUnchanged() {
92+
val (w, h) = RichSpanStyle.Image.clampToMaxWidth(
93+
width = 400.sp,
94+
height = 300.sp,
95+
maxWidth = 400.sp,
96+
)
97+
assertEquals(400.sp, w)
98+
assertEquals(300.sp, h)
99+
}
100+
101+
@Test
102+
fun unspecifiedHeightStaysUnspecified() {
103+
val (w, h) = RichSpanStyle.Image.clampToMaxWidth(
104+
width = 1500.sp,
105+
height = TextUnit.Unspecified,
106+
maxWidth = 300.sp,
107+
)
108+
assertEquals(300.sp, w)
109+
assertEquals(TextUnit.Unspecified, h)
110+
}
111+
}

0 commit comments

Comments
 (0)