1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-2968 Chats | Enhancement | Allow applying markdown in the chatbox (#2384)

This commit is contained in:
Evgenii Kozlov 2025-05-07 17:52:54 +02:00 committed by GitHub
parent 34d9e91d77
commit 0e9a997998
Signed by: github
GPG key ID: B5690EEEBB952194
3 changed files with 256 additions and 2 deletions

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -39,16 +40,24 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.common.DEFAULT_DISABLED_ALPHA
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.common.FULL_ALPHA
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.views.BodyRegular
@ -102,6 +111,8 @@ fun ChatBox(
val focus = LocalFocusManager.current
var isFocused by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxWidth()
@ -279,6 +290,7 @@ fun ChatBox(
text = text,
spans = spans,
onValueChange = onValueChange,
onFocusChanged = { isFocused = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(chatBoxFocusRequester)
@ -355,6 +367,54 @@ fun ChatBox(
}
}
}
if (!isFocused) return@Column
// Markup panel
ChatBoxMarkup(
onMarkupEvent = { event ->
scope.launch {
val selection = text.selection
if (selection.start == selection.end) return@launch // No selection, nothing to apply
val newSpan = when (event) {
ChatMarkupEvent.Bold -> ChatBoxSpan.Markup(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = selection.start,
end = selection.end,
type = ChatBoxSpan.Markup.BOLD
)
ChatMarkupEvent.Italic -> ChatBoxSpan.Markup(
style = SpanStyle(fontStyle = FontStyle.Italic),
start = selection.start,
end = selection.end,
type = ChatBoxSpan.Markup.ITALIC
)
ChatMarkupEvent.Strike -> ChatBoxSpan.Markup(
style = SpanStyle(textDecoration = TextDecoration.LineThrough),
start = selection.start,
end = selection.end,
type = ChatBoxSpan.Markup.STRIKETHROUGH
)
ChatMarkupEvent.Underline -> ChatBoxSpan.Markup(
style = SpanStyle(textDecoration = TextDecoration.Underline),
start = selection.start,
end = selection.end,
type = ChatBoxSpan.Markup.UNDERLINE
)
ChatMarkupEvent.Code -> ChatBoxSpan.Markup(
style = SpanStyle(fontFamily = FontFamily.Monospace),
start = selection.start,
end = selection.end,
type = ChatBoxSpan.Markup.CODE
)
}
val updatedSpans = toggleSpan(text, spans, newSpan)
onValueChange(text, updatedSpans)
}
}
)
}
}
@ -363,7 +423,8 @@ private fun ChatBoxUserInput(
modifier: Modifier,
text: TextFieldValue,
spans: List<ChatBoxSpan>,
onValueChange: (TextFieldValue, List<ChatBoxSpan>) -> Unit
onValueChange: (TextFieldValue, List<ChatBoxSpan>) -> Unit,
onFocusChanged: (Boolean) -> Unit
) {
BasicTextField(
value = text,
@ -402,6 +463,9 @@ private fun ChatBoxUserInput(
is ChatBoxSpan.Mention -> {
span.copy(start = newStart, end = newEnd)
}
is ChatBoxSpan.Markup -> {
span.copy(start = newStart, end = newEnd)
}
}
} else {
Timber.d("Removing span: $span")
@ -423,6 +487,9 @@ private fun ChatBoxUserInput(
top = 16.dp,
bottom = 16.dp
)
.onFocusChanged { state ->
onFocusChanged(state.isFocused)
}
,
cursorBrush = SolidColor(colorResource(id = R.color.palette_system_blue)),
maxLines = 5,
@ -436,4 +503,152 @@ private fun ChatBoxUserInput(
},
visualTransformation = AnnotatedTextTransformation(spans)
)
}
/**
* Toggles a text span (markup) on the selected text range.
* If the range already has a conflicting span, it is removed or adjusted.
*
* @param text The current TextFieldValue.
* @param spans The list of existing spans.
* @param newSpan The new span to be applied (or toggled).
* @return A new list of spans with the toggled result.
*/
fun toggleSpan(
text: TextFieldValue,
spans: List<ChatBoxSpan>,
newSpan: ChatBoxSpan.Markup
): List<ChatBoxSpan> {
val selectionStart = minOf(text.selection.start, text.selection.end)
val selectionEnd = maxOf(text.selection.start, text.selection.end)
if (selectionStart == selectionEnd) return spans // No selection, nothing to apply
val updatedSpans = spans.toMutableList()
val finalSpans = mutableListOf<ChatBoxSpan>()
var spanToggled = false
// Process existing spans and toggle where needed
for (span in updatedSpans) {
if (span !is ChatBoxSpan.Markup || span.type != newSpan.type) {
finalSpans.add(span)
continue
}
// Span completely outside the selection - keep it
if (span.end <= selectionStart || span.start >= selectionEnd) {
finalSpans.add(span)
continue
}
// Toggle logic:
spanToggled = true
// Case 1: Selection fully covers the span - remove it (toggle off)
if (selectionStart <= span.start && selectionEnd >= span.end) {
continue // Skip adding this span
}
// Case 2: Partial overlap - split or trim
if (span.start < selectionStart) {
finalSpans.add(span.copy(end = selectionStart)) // Left part
}
if (span.end > selectionEnd) {
finalSpans.add(span.copy(start = selectionEnd)) // Right part
}
}
// If no span was toggled off, add the new span
if (!spanToggled) {
finalSpans.add(newSpan.copy(start = selectionStart, end = selectionEnd))
}
// Sort and merge contiguous spans
return finalSpans
.sortedBy { it.start }
.fold(mutableListOf<ChatBoxSpan>()) { acc, span ->
if (acc.isNotEmpty()) {
val last = acc.last()
if (last is ChatBoxSpan.Markup && span is ChatBoxSpan.Markup &&
last.type == span.type && last.end == span.start
) {
// Merge contiguous spans of the same type
acc[acc.lastIndex] = last.copy(end = span.end)
} else {
acc.add(span)
}
} else {
acc.add(span)
}
acc
}
}
@Composable
fun ChatBoxMarkup(
onMarkupEvent: (ChatMarkupEvent) -> Unit = {}
) {
Row(
modifier = Modifier.fillMaxWidth().height(52.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.clickable {
onMarkupEvent(ChatMarkupEvent.Bold)
},
text = "B",
color = colorResource(R.color.text_primary)
)
Text(
modifier = Modifier.clickable {
onMarkupEvent(ChatMarkupEvent.Italic)
},
text = "i",
style = TextStyle(
fontStyle = FontStyle.Italic
),
color = colorResource(R.color.text_primary)
)
Text(
modifier = Modifier.clickable {
onMarkupEvent(ChatMarkupEvent.Strike)
},
style = TextStyle(
textDecoration = TextDecoration.LineThrough
),
text = " S ",
color = colorResource(R.color.text_primary),
)
Text(
modifier = Modifier.clickable {
onMarkupEvent(ChatMarkupEvent.Underline)
},
style = TextStyle(
textDecoration = TextDecoration.Underline
),
text = "U",
color = colorResource(R.color.text_primary)
)
Text(
modifier = Modifier.clickable {
onMarkupEvent(ChatMarkupEvent.Code)
},
text = "<>",
color = colorResource(R.color.text_primary)
)
}
}
sealed class ChatMarkupEvent {
data object Bold : ChatMarkupEvent()
data object Italic : ChatMarkupEvent()
data object Strike : ChatMarkupEvent()
data object Underline: ChatMarkupEvent()
data object Code: ChatMarkupEvent()
}
@DefaultPreviews
@Composable
fun ChatBoxMarkupPreview() {
ChatBoxMarkup()
}

View file

@ -150,7 +150,7 @@ fun ChatScreenWrapper(
onMessageSent = { text, spans ->
vm.onMessageSent(
msg = text,
markup = spans.map { span ->
markup = spans.mapNotNull { span ->
when(span) {
is ChatBoxSpan.Mention -> {
Block.Content.Text.Mark(
@ -159,6 +159,24 @@ fun ChatScreenWrapper(
range = span.start..span.end
)
}
is ChatBoxSpan.Markup -> {
val type = when(span.type) {
ChatBoxSpan.Markup.BOLD -> Block.Content.Text.Mark.Type.BOLD
ChatBoxSpan.Markup.ITALIC -> Block.Content.Text.Mark.Type.ITALIC
ChatBoxSpan.Markup.STRIKETHROUGH -> Block.Content.Text.Mark.Type.STRIKETHROUGH
ChatBoxSpan.Markup.CODE -> Block.Content.Text.Mark.Type.KEYBOARD
ChatBoxSpan.Markup.UNDERLINE -> Block.Content.Text.Mark.Type.UNDERLINE
else -> null
}
if (type != null) {
Block.Content.Text.Mark(
type = type,
range = span.start..span.end
)
} else {
null
}
}
}
}
)
@ -587,6 +605,12 @@ fun ChatScreen(
end = span.end + lengthDifference
)
}
is ChatBoxSpan.Markup -> {
span.copy(
start = span.start + lengthDifference,
end = span.end + lengthDifference
)
}
}
} else {
span

View file

@ -91,6 +91,21 @@ sealed class ChatBoxSpan {
override val end: Int,
val param: Id
) : ChatBoxSpan()
data class Markup(
override val style: SpanStyle,
override val start: Int,
override val end: Int,
val type: Int
) : ChatBoxSpan() {
companion object {
const val BOLD = 0
const val ITALIC = 1
const val STRIKETHROUGH = 2
const val UNDERLINE = 3
const val CODE = 4
}
}
}
const val DEFAULT_MENTION_SPAN_TAG = "@-mention"