Merge main into feat-156-154-aol-provider
This commit is contained in:
@@ -56,6 +56,8 @@ import org.libremail.richtext.RichStyle
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextEditing
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import org.libremail.ui.compose.format.ColorSwatch
|
||||
import org.libremail.ui.compose.format.ColorSwatchRow
|
||||
|
||||
/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */
|
||||
private const val URL_TAG = "libremail:url"
|
||||
@@ -71,15 +73,17 @@ internal const val STYLE_TAG = "libremail:style"
|
||||
internal const val IMAGE_TAG = "libremail:image"
|
||||
|
||||
/**
|
||||
* A rich-text body editor: a formatting toolbar (bold / italic / underline, bulleted + numbered
|
||||
* lists, block quote, and link) above a rounded [OutlinedTextField]. It converts its
|
||||
* [AnnotatedString] to the app's [RichTextContent] model and reports both the plaintext form and its
|
||||
* HTML — or null HTML when nothing is formatted, so an unformatted message stays plaintext-only and
|
||||
* feels exactly like the old editor.
|
||||
* A rich-text body editor: a formatting toolbar (bold / italic / underline / strikethrough, font
|
||||
* color and highlight, bulleted + numbered lists, block quote, and link) above a rounded
|
||||
* [OutlinedTextField]. It converts its [AnnotatedString] to the app's [RichTextContent] model and
|
||||
* reports both the plaintext form and its HTML — or null HTML when nothing is formatted, so an
|
||||
* unformatted message stays plaintext-only and feels exactly like the old editor.
|
||||
*
|
||||
* The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all
|
||||
* work as usual; each toolbar button exposes its accessible action label via `onClickLabel` on its
|
||||
* [Modifier.clickable] (not a `contentDescription`), and still carries toggle state for accessibility.
|
||||
* The font-color and highlight buttons open a [ColorPickerDialog] built on the shared
|
||||
* [ColorSwatchRow], whose individual swatches carry their own `contentDescription` instead.
|
||||
*
|
||||
* [resolveFont] maps a CSS font-family stack to a Compose [FontFamily] for display; the default
|
||||
* resolves nothing, leaving the system font (the model still round-trips the CSS value untouched).
|
||||
@@ -119,6 +123,8 @@ fun RichTextBodyField(
|
||||
}
|
||||
|
||||
var showLinkDialog by remember { mutableStateOf(false) }
|
||||
var showFontColorPicker by remember { mutableStateOf(false) }
|
||||
var showHighlightPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier) {
|
||||
FormattingToolbar(
|
||||
@@ -126,6 +132,8 @@ fun RichTextBodyField(
|
||||
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor, resolveFont)) },
|
||||
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor, resolveFont)) },
|
||||
onLink = { showLinkDialog = true },
|
||||
onFontColor = { showFontColorPicker = true },
|
||||
onHighlight = { showHighlightPicker = true },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
@@ -149,6 +157,56 @@ fun RichTextBodyField(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showFontColorPicker) {
|
||||
val current = RichTextEditing.styleAt(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
RichStyle.FontColor::class.java,
|
||||
)
|
||||
ColorPickerDialog(
|
||||
title = stringResource(R.string.format_color),
|
||||
swatches = fontColorSwatches(),
|
||||
selectedArgb = current?.argb,
|
||||
onDismiss = { showFontColorPicker = false },
|
||||
onSelect = { argb ->
|
||||
emit(
|
||||
if (argb != null) {
|
||||
applyStyle(value, RichStyle.FontColor(argb), linkColor, resolveFont)
|
||||
} else {
|
||||
clearStyle(value, RichStyle.FontColor::class.java, linkColor, resolveFont)
|
||||
},
|
||||
)
|
||||
showFontColorPicker = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showHighlightPicker) {
|
||||
val current = RichTextEditing.styleAt(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
RichStyle.Highlight::class.java,
|
||||
)
|
||||
ColorPickerDialog(
|
||||
title = stringResource(R.string.format_highlight),
|
||||
swatches = highlightSwatches(),
|
||||
selectedArgb = current?.argb,
|
||||
onDismiss = { showHighlightPicker = false },
|
||||
onSelect = { argb ->
|
||||
emit(
|
||||
if (argb != null) {
|
||||
applyStyle(value, RichStyle.Highlight(argb), linkColor, resolveFont)
|
||||
} else {
|
||||
clearStyle(value, RichStyle.Highlight::class.java, linkColor, resolveFont)
|
||||
},
|
||||
)
|
||||
showHighlightPicker = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -157,6 +215,8 @@ private fun FormattingToolbar(
|
||||
onToggleStyle: (RichStyle) -> Unit,
|
||||
onToggleBlock: (BlockMarker) -> Unit,
|
||||
onLink: () -> Unit,
|
||||
onFontColor: () -> Unit,
|
||||
onHighlight: () -> Unit,
|
||||
) {
|
||||
val content = value.annotatedString.toRichContent()
|
||||
val start = value.selection.min
|
||||
@@ -197,6 +257,22 @@ private fun FormattingToolbar(
|
||||
strikethrough = true,
|
||||
onClick = { onToggleStyle(RichStyle.Strikethrough) },
|
||||
)
|
||||
val fontColorArgb = RichTextEditing.styleAt(content, start, end, RichStyle.FontColor::class.java)?.argb
|
||||
FormatButton(
|
||||
label = "A",
|
||||
description = stringResource(R.string.format_color),
|
||||
active = fontColorArgb != null,
|
||||
tint = fontColorArgb?.let { Color(it) },
|
||||
onClick = onFontColor,
|
||||
)
|
||||
val highlightArgb = RichTextEditing.styleAt(content, start, end, RichStyle.Highlight::class.java)?.argb
|
||||
FormatButton(
|
||||
label = "H",
|
||||
description = stringResource(R.string.format_highlight),
|
||||
active = false,
|
||||
swatchColor = highlightArgb?.let { Color(it) },
|
||||
onClick = onHighlight,
|
||||
)
|
||||
FormatButton(
|
||||
label = "•",
|
||||
description = stringResource(R.string.format_bullet_list),
|
||||
@@ -234,10 +310,12 @@ private fun FormatButton(
|
||||
fontStyle: FontStyle? = null,
|
||||
underline: Boolean = false,
|
||||
strikethrough: Boolean = false,
|
||||
tint: Color? = null,
|
||||
swatchColor: Color? = null,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val background = if (active) colors.secondaryContainer else Color.Transparent
|
||||
val textColor = if (active) colors.onSecondaryContainer else colors.onSurfaceVariant
|
||||
val background = swatchColor ?: if (active) colors.secondaryContainer else Color.Transparent
|
||||
val textColor = tint ?: if (active) colors.onSecondaryContainer else colors.onSurfaceVariant
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
@@ -291,6 +369,59 @@ private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (Stri
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A color-swatch picker shared by the font-color and highlight toolbar buttons. [title] names which
|
||||
* one; [swatches] is the fixed palette; [selectedArgb] rings the swatch (if any) uniformly applied
|
||||
* over the current selection. Tapping any swatch - or the built-in "no color" entry, which
|
||||
* [ColorSwatchRow] reports as a null argument - calls [onSelect]; the caller applies the change and
|
||||
* dismisses, mirroring how [LinkDialog]'s `onConfirm` is wired.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColorPickerDialog(
|
||||
title: String,
|
||||
swatches: List<ColorSwatch>,
|
||||
selectedArgb: Int?,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (Int?) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
ColorSwatchRow(
|
||||
swatches = swatches,
|
||||
selectedArgb = selectedArgb,
|
||||
onSelect = onSelect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** ~8 fixed font colors for the font-color picker; "no color" is [ColorSwatchRow]'s own leading entry. */
|
||||
@Composable
|
||||
private fun fontColorSwatches(): List<ColorSwatch> = listOf(
|
||||
ColorSwatch(0xFF000000.toInt(), stringResource(R.string.format_color_black)),
|
||||
ColorSwatch(0xFF616161.toInt(), stringResource(R.string.format_color_gray)),
|
||||
ColorSwatch(0xFFD32F2F.toInt(), stringResource(R.string.format_color_red)),
|
||||
ColorSwatch(0xFFEF6C00.toInt(), stringResource(R.string.format_color_orange)),
|
||||
ColorSwatch(0xFFF9A825.toInt(), stringResource(R.string.format_color_yellow)),
|
||||
ColorSwatch(0xFF2E7D32.toInt(), stringResource(R.string.format_color_green)),
|
||||
ColorSwatch(0xFF1565C0.toInt(), stringResource(R.string.format_color_blue)),
|
||||
ColorSwatch(0xFF6A1B9A.toInt(), stringResource(R.string.format_color_purple)),
|
||||
)
|
||||
|
||||
/** Typical marker colors for the highlight picker; "none" is [ColorSwatchRow]'s own leading entry. */
|
||||
@Composable
|
||||
private fun highlightSwatches(): List<ColorSwatch> = listOf(
|
||||
ColorSwatch(0xFFFFF59D.toInt(), stringResource(R.string.format_highlight_yellow)),
|
||||
ColorSwatch(0xFFA5D6A7.toInt(), stringResource(R.string.format_highlight_green)),
|
||||
ColorSwatch(0xFF80DEEA.toInt(), stringResource(R.string.format_highlight_cyan)),
|
||||
ColorSwatch(0xFFF48FB1.toInt(), stringResource(R.string.format_highlight_pink)),
|
||||
)
|
||||
|
||||
// --- editor-op plumbing (TextFieldValue <-> RichTextContent) ---
|
||||
|
||||
/** Toggles [style] over the selection and rebuilds the field value (one-liner for toolbar wiring). */
|
||||
@@ -342,6 +473,35 @@ internal fun applyLink(
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears every span of style kind [kind] over the selection - the color pickers' "no color"/"none"
|
||||
* entry, which must remove the style outright regardless of its value. [applyStyle] cannot do this:
|
||||
* it delegates to [RichTextEditing.toggleStyle], which only clears when the selection is already
|
||||
* uniformly one exact value, and otherwise applies that value instead of removing it.
|
||||
*/
|
||||
internal fun <T : RichStyle> clearStyle(
|
||||
value: TextFieldValue,
|
||||
kind: Class<T>,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
): TextFieldValue {
|
||||
val start = value.selection.min
|
||||
val end = value.selection.max
|
||||
if (start >= end) return value
|
||||
val content = value.annotatedString.toRichContent()
|
||||
val kept = content.spans.flatMap { span ->
|
||||
when {
|
||||
!kind.isInstance(span.style) || span.end <= start || span.start >= end -> listOf(span)
|
||||
else -> buildList {
|
||||
if (span.start < start) add(span.copy(end = start))
|
||||
if (span.end > end) add(span.copy(start = end))
|
||||
}
|
||||
}
|
||||
}
|
||||
val updated = content.copy(spans = kept.sortedBy { it.start })
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
|
||||
}
|
||||
|
||||
private fun seedContent(body: String, bodyHtml: String?): RichTextContent =
|
||||
if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
|
||||
|
||||
|
||||
@@ -107,6 +107,20 @@
|
||||
<string name="format_link_apply">Link</string>
|
||||
<string name="format_link_needs_selection">Select some text first, then add a link.</string>
|
||||
<string name="format_color_none">No color</string>
|
||||
<string name="format_color">Font color</string>
|
||||
<string name="format_color_black">Black</string>
|
||||
<string name="format_color_gray">Gray</string>
|
||||
<string name="format_color_red">Red</string>
|
||||
<string name="format_color_orange">Orange</string>
|
||||
<string name="format_color_yellow">Yellow</string>
|
||||
<string name="format_color_green">Green</string>
|
||||
<string name="format_color_blue">Blue</string>
|
||||
<string name="format_color_purple">Purple</string>
|
||||
<string name="format_highlight">Highlight</string>
|
||||
<string name="format_highlight_yellow">Yellow</string>
|
||||
<string name="format_highlight_green">Green</string>
|
||||
<string name="format_highlight_cyan">Cyan</string>
|
||||
<string name="format_highlight_pink">Pink</string>
|
||||
|
||||
<!-- Drafts -->
|
||||
<string name="drafts_title">Drafts</string>
|
||||
|
||||
@@ -164,6 +164,49 @@ class RichTextEditorTest {
|
||||
assertTrue(result.annotatedString.toRichContent().spans.isEmpty())
|
||||
}
|
||||
|
||||
// --- clearStyle ---
|
||||
|
||||
@Test
|
||||
fun `clearStyle removes a font color span regardless of its value`() {
|
||||
val value = field("hello", TextRange(0, 5))
|
||||
val colored = applyStyle(value, RichStyle.FontColor(0xFFFF0000.toInt()), linkColor)
|
||||
val cleared = clearStyle(colored, RichStyle.FontColor::class.java, linkColor)
|
||||
assertTrue(cleared.annotatedString.toRichContent().spans.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearStyle splits a span that only partially overlaps the selection`() {
|
||||
val value = field("abcdef", TextRange(0, 6))
|
||||
val colored = applyStyle(value, RichStyle.Highlight(0xFFFFFF00.toInt()), linkColor)
|
||||
val narrowed = colored.copy(selection = TextRange(2, 4))
|
||||
val cleared = clearStyle(narrowed, RichStyle.Highlight::class.java, linkColor)
|
||||
assertEquals(
|
||||
listOf(
|
||||
RichSpan(0, 2, RichStyle.Highlight(0xFFFFFF00.toInt())),
|
||||
RichSpan(4, 6, RichStyle.Highlight(0xFFFFFF00.toInt())),
|
||||
),
|
||||
cleared.annotatedString.toRichContent().spans,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearStyle only removes spans of the given kind, leaving other styles intact`() {
|
||||
val value = field("hi", TextRange(0, 2))
|
||||
val bolded = applyStyle(value, RichStyle.Bold, linkColor)
|
||||
val both = applyStyle(bolded, RichStyle.FontColor(0xFF000000.toInt()), linkColor)
|
||||
val cleared = clearStyle(both, RichStyle.FontColor::class.java, linkColor)
|
||||
assertEquals(listOf(RichSpan(0, 2, RichStyle.Bold)), cleared.annotatedString.toRichContent().spans)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearStyle is a no-op with a collapsed selection`() {
|
||||
val value = field("hello", TextRange(0, 5))
|
||||
val colored = applyStyle(value, RichStyle.FontColor(0xFFFF0000.toInt()), linkColor)
|
||||
val collapsed = colored.copy(selection = TextRange(2))
|
||||
val result = clearStyle(collapsed, RichStyle.FontColor::class.java, linkColor)
|
||||
assertEquals(colored.annotatedString.toRichContent().spans, result.annotatedString.toRichContent().spans)
|
||||
}
|
||||
|
||||
// --- applyBlock ---
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user