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:
parent
34d9e91d77
commit
0e9a997998
3 changed files with 256 additions and 2 deletions
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue