diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt index bc80dca5d1..b042df0c8a 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt @@ -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, - onValueChange: (TextFieldValue, List) -> Unit + onValueChange: (TextFieldValue, List) -> 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, + newSpan: ChatBoxSpan.Markup +): List { + 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() + 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()) { 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() } \ No newline at end of file diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt index 68b085b09b..8ba3912a22 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt @@ -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 diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Utils.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Utils.kt index 098a07fc9e..8329f23712 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Utils.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Utils.kt @@ -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"