Compare commits
10 Commits
b57e48fe20
...
ad08af7f0c
| Author | SHA1 | Date | |
|---|---|---|---|
| ad08af7f0c | |||
| 9fe5f35923 | |||
| 78925dd61d | |||
| 1ac3d93f74 | |||
| 6ad24974e0 | |||
| e825a1ef30 | |||
| 7fcf1195e1 | |||
| a10482b794 | |||
| 419761e34d | |||
| 988896c080 |
@@ -394,6 +394,10 @@ fun ChatDetailScreen(
|
||||
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
||||
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
|
||||
|
||||
// 🔤 TEXT SELECTION - Telegram-style character-level selection
|
||||
val textSelectionHelper = remember { com.rosetta.messenger.ui.chats.components.TextSelectionHelper() }
|
||||
LaunchedEffect(Unit) { textSelectionHelper.setMagnifierView(view) }
|
||||
|
||||
// 💬 MESSAGE CONTEXT MENU STATE
|
||||
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
@@ -838,6 +842,7 @@ fun ChatDetailScreen(
|
||||
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
|
||||
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
|
||||
{ messageId, canSelect ->
|
||||
textSelectionHelper.clear()
|
||||
if (canSelect && !selectedMessages.contains(messageId)) {
|
||||
selectedMessages = selectedMessages + messageId
|
||||
}
|
||||
@@ -886,6 +891,13 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 🔤 Сброс текстового выделения при скролле
|
||||
LaunchedEffect(listState.isScrollInProgress) {
|
||||
if (listState.isScrollInProgress && textSelectionHelper.isActive) {
|
||||
textSelectionHelper.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
|
||||
val displayReplyMessages =
|
||||
remember(replyMessages, messages) {
|
||||
@@ -3025,6 +3037,7 @@ fun ChatDetailScreen(
|
||||
else -> {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
userScrollEnabled = !textSelectionHelper.movingHandle,
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.nestedScroll(
|
||||
@@ -3164,6 +3177,8 @@ fun ChatDetailScreen(
|
||||
MessageBubble(
|
||||
message =
|
||||
message,
|
||||
textSelectionHelper =
|
||||
textSelectionHelper,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
hasWallpaper =
|
||||
@@ -3644,6 +3659,11 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🔤 Text selection overlay
|
||||
com.rosetta.messenger.ui.chats.components.TextSelectionOverlay(
|
||||
helper = textSelectionHelper,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +320,7 @@ fun TypingIndicator(
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: ChatMessage,
|
||||
textSelectionHelper: com.rosetta.messenger.ui.chats.components.TextSelectionHelper? = null,
|
||||
isDarkTheme: Boolean,
|
||||
hasWallpaper: Boolean = false,
|
||||
isSystemSafeChat: Boolean = false,
|
||||
@@ -400,6 +401,10 @@ fun MessageBubble(
|
||||
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
|
||||
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
|
||||
}
|
||||
var textViewRef by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiTextView?>(null) }
|
||||
val selectionDragEndHandler: (() -> Unit)? = if (textSelectionHelper != null) {
|
||||
{ textSelectionHelper.hideMagnifier(); textSelectionHelper.endHandleDrag() }
|
||||
} else null
|
||||
val linksEnabled = !isSelectionMode
|
||||
val textClickHandler: (() -> Unit)? = onClick
|
||||
val mentionClickHandler: ((String) -> Unit)? =
|
||||
@@ -1066,7 +1071,31 @@ fun MessageBubble(
|
||||
onClick =
|
||||
textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥 Long press для selection
|
||||
onLongClick, // 🔥 Long press для selection
|
||||
onViewCreated = { textViewRef = it },
|
||||
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
|
||||
val info = textViewRef?.getLayoutInfo()
|
||||
if (info != null) {
|
||||
textSelectionHelper.startSelection(
|
||||
messageId = message.id,
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
|
||||
textSelectionHelper.moveHandle(
|
||||
(tx - textSelectionHelper.overlayWindowX),
|
||||
(ty - textSelectionHelper.overlayWindowY)
|
||||
)
|
||||
textSelectionHelper.showMagnifier(
|
||||
(tx - textSelectionHelper.overlayWindowX),
|
||||
(ty - textSelectionHelper.overlayWindowY)
|
||||
)
|
||||
} else null,
|
||||
onSelectionDragEnd = selectionDragEndHandler
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
@@ -1157,11 +1186,20 @@ fun MessageBubble(
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
// Long
|
||||
// press
|
||||
// для
|
||||
// selection
|
||||
onLongClick, // 🔥 Long press для selection
|
||||
onViewCreated = { textViewRef = it },
|
||||
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY ->
|
||||
val info = textViewRef?.getLayoutInfo()
|
||||
if (info != null) {
|
||||
textSelectionHelper.startSelection(
|
||||
messageId = message.id,
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
@@ -1261,11 +1299,31 @@ fun MessageBubble(
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
// Long
|
||||
// press
|
||||
// для
|
||||
// selection
|
||||
onLongClick, // 🔥 Long press для selection
|
||||
onViewCreated = { textViewRef = it },
|
||||
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
|
||||
val info = textViewRef?.getLayoutInfo()
|
||||
if (info != null) {
|
||||
textSelectionHelper.startSelection(
|
||||
messageId = message.id,
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
|
||||
textSelectionHelper.moveHandle(
|
||||
(tx - textSelectionHelper.overlayWindowX),
|
||||
(ty - textSelectionHelper.overlayWindowY)
|
||||
)
|
||||
textSelectionHelper.showMagnifier(
|
||||
(tx - textSelectionHelper.overlayWindowX),
|
||||
(ty - textSelectionHelper.overlayWindowY)
|
||||
)
|
||||
} else null,
|
||||
onSelectionDragEnd = selectionDragEndHandler
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.text.Layout
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class LayoutInfo(
|
||||
val layout: Layout,
|
||||
val windowX: Int,
|
||||
val windowY: Int,
|
||||
val text: CharSequence
|
||||
)
|
||||
|
||||
class TextSelectionHelper {
|
||||
|
||||
var selectionStart by mutableIntStateOf(-1)
|
||||
private set
|
||||
var selectionEnd by mutableIntStateOf(-1)
|
||||
private set
|
||||
var selectedMessageId by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var layoutInfo by mutableStateOf<LayoutInfo?>(null)
|
||||
private set
|
||||
var isActive by mutableStateOf(false)
|
||||
private set
|
||||
var handleViewProgress by mutableFloatStateOf(0f)
|
||||
private set
|
||||
var movingHandle by mutableStateOf(false)
|
||||
private set
|
||||
var movingHandleStart by mutableStateOf(false)
|
||||
private set
|
||||
// Telegram: isOneTouch = true during initial long-press drag (before finger lifts)
|
||||
var isOneTouch by mutableStateOf(false)
|
||||
private set
|
||||
// Telegram: direction not determined yet — first drag decides start or end handle
|
||||
private var movingDirectionSettling = false
|
||||
private var movingOffsetX = 0f
|
||||
private var movingOffsetY = 0f
|
||||
|
||||
var startHandleX by mutableFloatStateOf(0f)
|
||||
var startHandleY by mutableFloatStateOf(0f)
|
||||
var endHandleX by mutableFloatStateOf(0f)
|
||||
var endHandleY by mutableFloatStateOf(0f)
|
||||
|
||||
// Overlay position in window — set by TextSelectionOverlay
|
||||
var overlayWindowX = 0f
|
||||
var overlayWindowY = 0f
|
||||
|
||||
val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart
|
||||
|
||||
fun startSelection(
|
||||
messageId: String,
|
||||
info: LayoutInfo,
|
||||
touchX: Int,
|
||||
touchY: Int,
|
||||
view: View?
|
||||
) {
|
||||
val layout = info.layout
|
||||
val localX = touchX - info.windowX
|
||||
val localY = touchY - info.windowY
|
||||
|
||||
val line = layout.getLineForVertical(localY)
|
||||
val hx = localX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
|
||||
val offset = layout.getOffsetForHorizontal(line, hx)
|
||||
|
||||
val text = info.text
|
||||
var start = offset
|
||||
var end = offset
|
||||
|
||||
while (start > 0 && Character.isLetterOrDigit(text[start - 1])) start--
|
||||
while (end < text.length && Character.isLetterOrDigit(text[end])) end++
|
||||
|
||||
if (start == end && end < text.length) end++
|
||||
|
||||
selectedMessageId = messageId
|
||||
layoutInfo = info
|
||||
selectionStart = start
|
||||
selectionEnd = end
|
||||
isActive = true
|
||||
handleViewProgress = 1f
|
||||
|
||||
// Telegram: immediately enter drag mode — user can drag without lifting finger
|
||||
movingHandle = true
|
||||
movingDirectionSettling = true
|
||||
isOneTouch = true
|
||||
movingOffsetX = 0f
|
||||
movingOffsetY = 0f
|
||||
|
||||
view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
showToolbar = false
|
||||
}
|
||||
|
||||
fun updateSelectionStart(charOffset: Int) {
|
||||
if (!isActive) return
|
||||
val text = layoutInfo?.text ?: return
|
||||
val newStart = charOffset.coerceIn(0, text.length)
|
||||
if (newStart >= selectionEnd) return
|
||||
val changed = newStart != selectionStart
|
||||
selectionStart = newStart
|
||||
if (changed) hapticOnSelectionChange()
|
||||
}
|
||||
|
||||
fun updateSelectionEnd(charOffset: Int) {
|
||||
if (!isActive) return
|
||||
val text = layoutInfo?.text ?: return
|
||||
val newEnd = charOffset.coerceIn(0, text.length)
|
||||
if (newEnd <= selectionStart) return
|
||||
val changed = newEnd != selectionEnd
|
||||
selectionEnd = newEnd
|
||||
if (changed) hapticOnSelectionChange()
|
||||
}
|
||||
|
||||
private fun hapticOnSelectionChange() {
|
||||
magnifierView?.performHapticFeedback(
|
||||
HapticFeedbackConstants.TEXT_HANDLE_MOVE,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
|
||||
)
|
||||
}
|
||||
|
||||
fun beginHandleDrag(isStart: Boolean, touchX: Float, touchY: Float) {
|
||||
movingHandle = true
|
||||
movingHandleStart = isStart
|
||||
movingOffsetX = if (isStart) startHandleX - touchX else endHandleX - touchX
|
||||
movingOffsetY = if (isStart) startHandleY - touchY else endHandleY - touchY
|
||||
}
|
||||
|
||||
fun moveHandle(touchX: Float, touchY: Float) {
|
||||
if (!movingHandle) return
|
||||
val x = (touchX + movingOffsetX).toInt()
|
||||
val y = (touchY + movingOffsetY).toInt()
|
||||
val offset = getCharOffsetFromCoords(x, y)
|
||||
if (offset < 0) return
|
||||
|
||||
// Telegram: first drag determines which handle to move
|
||||
if (movingDirectionSettling) {
|
||||
if (offset < selectionStart) {
|
||||
movingDirectionSettling = false
|
||||
movingHandleStart = true
|
||||
} else if (offset > selectionEnd) {
|
||||
movingDirectionSettling = false
|
||||
movingHandleStart = false
|
||||
} else {
|
||||
return // still within selected word, wait for more movement
|
||||
}
|
||||
}
|
||||
|
||||
if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset)
|
||||
}
|
||||
|
||||
fun endHandleDrag() {
|
||||
movingHandle = false
|
||||
movingDirectionSettling = false
|
||||
isOneTouch = false
|
||||
showFloatingToolbar()
|
||||
}
|
||||
|
||||
var showToolbar by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun showFloatingToolbar() {
|
||||
if (isInSelectionMode && !movingHandle) {
|
||||
showToolbar = true
|
||||
}
|
||||
}
|
||||
|
||||
fun hideFloatingToolbar() {
|
||||
showToolbar = false
|
||||
}
|
||||
|
||||
private var magnifier: android.widget.Magnifier? = null
|
||||
private var magnifierView: View? = null
|
||||
|
||||
fun setMagnifierView(view: View?) {
|
||||
magnifierView = view
|
||||
}
|
||||
|
||||
fun showMagnifier(overlayLocalX: Float, overlayLocalY: Float) {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
|
||||
val view = magnifierView ?: return
|
||||
if (!movingHandle) return
|
||||
if (magnifier == null) {
|
||||
magnifier = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
android.widget.Magnifier.Builder(view)
|
||||
.setSize(240, 64)
|
||||
.setCornerRadius(12f)
|
||||
.setElevation(4f)
|
||||
.setDefaultSourceToMagnifierOffset(0, -96)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
android.widget.Magnifier(view)
|
||||
}
|
||||
}
|
||||
val info = layoutInfo ?: return
|
||||
|
||||
// Magnifier should show at the HANDLE position (current char), not finger
|
||||
// Use handle X for horizontal, and line center for vertical
|
||||
val handleX = if (movingHandleStart) startHandleX else endHandleX
|
||||
val handleY = if (movingHandleStart) startHandleY else endHandleY
|
||||
val activeOffset = if (movingHandleStart) selectionStart else selectionEnd
|
||||
val layout = info.layout
|
||||
val line = layout.getLineForOffset(activeOffset.coerceIn(0, info.text.length))
|
||||
val lineCenter = (layout.getLineTop(line) + layout.getLineBottom(line)) / 2f
|
||||
|
||||
// Convert to view-local coordinates
|
||||
val viewLoc = IntArray(2)
|
||||
view.getLocationInWindow(viewLoc)
|
||||
val sourceX = (handleX + overlayWindowX - viewLoc[0]).coerceIn(0f, view.width.toFloat())
|
||||
val sourceY = (lineCenter + info.windowY - viewLoc[1]).coerceIn(0f, view.height.toFloat())
|
||||
magnifier?.show(sourceX, sourceY)
|
||||
}
|
||||
|
||||
fun hideMagnifier() {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
|
||||
magnifier?.dismiss()
|
||||
magnifier = null
|
||||
}
|
||||
|
||||
fun getCharOffsetFromCoords(overlayLocalX: Int, overlayLocalY: Int): Int {
|
||||
val info = layoutInfo ?: return -1
|
||||
// overlay-local → text-local: subtract text position relative to overlay
|
||||
val textLocalX = overlayLocalX - (info.windowX - overlayWindowX)
|
||||
val textLocalY = overlayLocalY - (info.windowY - overlayWindowY)
|
||||
val layout = info.layout
|
||||
val line = layout.getLineForVertical(textLocalY.toInt().coerceIn(0, layout.height))
|
||||
val hx = textLocalX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
|
||||
return layout.getOffsetForHorizontal(line, hx)
|
||||
}
|
||||
|
||||
fun getSelectedText(): CharSequence? {
|
||||
if (!isInSelectionMode) return null
|
||||
val text = layoutInfo?.text ?: return null
|
||||
val start = selectionStart.coerceIn(0, text.length)
|
||||
val end = selectionEnd.coerceIn(start, text.length)
|
||||
return text.subSequence(start, end)
|
||||
}
|
||||
|
||||
fun copySelectedText(context: Context) {
|
||||
val selectedText = getSelectedText() ?: return
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("selected_text", selectedText))
|
||||
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
||||
clear()
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val text = layoutInfo?.text ?: return
|
||||
selectionStart = 0
|
||||
selectionEnd = text.length
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
selectionStart = -1
|
||||
selectionEnd = -1
|
||||
selectedMessageId = null
|
||||
layoutInfo = null
|
||||
isActive = false
|
||||
handleViewProgress = 0f
|
||||
movingHandle = false
|
||||
movingDirectionSettling = false
|
||||
isOneTouch = false
|
||||
showToolbar = false
|
||||
hideMagnifier()
|
||||
}
|
||||
}
|
||||
|
||||
private val HandleSize = 22.dp
|
||||
private val HandleInset = 8.dp
|
||||
private val HighlightCorner = 6.dp
|
||||
private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f)
|
||||
private val HandleColor = PrimaryBlue
|
||||
|
||||
@Composable
|
||||
private fun FloatingToolbarPopup(
|
||||
helper: TextSelectionHelper
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(helper.isActive, helper.movingHandle) {
|
||||
if (helper.isActive && !helper.movingHandle && !helper.showToolbar) {
|
||||
delay(200)
|
||||
helper.showFloatingToolbar()
|
||||
}
|
||||
}
|
||||
|
||||
if (!helper.showToolbar || !helper.isInSelectionMode) return
|
||||
|
||||
val info = helper.layoutInfo ?: return
|
||||
val layout = info.layout
|
||||
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
|
||||
|
||||
// Toolbar position: centered above selection, in overlay-local coords
|
||||
// startHandleX/endHandleX are already overlay-local
|
||||
val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
|
||||
val selectionTopY = layout.getLineTop(startLine).toFloat() +
|
||||
(info.windowY - helper.overlayWindowY)
|
||||
val toolbarHeight = 40f // approximate toolbar height in px
|
||||
val toolbarWidth = 160f // approximate toolbar width in px
|
||||
val toolbarX = (selectionCenterX - toolbarWidth / 2f).coerceAtLeast(8f)
|
||||
val toolbarY = (selectionTopY - toolbarHeight - 12f).coerceAtLeast(0f)
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
offset = IntOffset(toolbarX.toInt(), toolbarY.toInt())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.shadow(4.dp, RoundedCornerShape(8.dp))
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF333333))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Copy",
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clickable { helper.copySelectedText(context) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
val allSelected = helper.selectionStart <= 0 &&
|
||||
helper.selectionEnd >= info.text.length
|
||||
if (!allSelected) {
|
||||
Text(
|
||||
text = "Select All",
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
helper.selectAll()
|
||||
helper.hideFloatingToolbar()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextSelectionOverlay(
|
||||
helper: TextSelectionHelper,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!helper.isInSelectionMode) return
|
||||
|
||||
val density = LocalDensity.current
|
||||
val handleSizePx = with(density) { HandleSize.toPx() }
|
||||
val handleInsetPx = with(density) { HandleInset.toPx() }
|
||||
val highlightCornerPx = with(density) { HighlightCorner.toPx() }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coords ->
|
||||
val pos = coords.positionInWindow()
|
||||
helper.overlayWindowX = pos.x
|
||||
helper.overlayWindowY = pos.y
|
||||
}
|
||||
) {
|
||||
FloatingToolbarPopup(helper = helper)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(helper.isActive) {
|
||||
if (!helper.isActive) return@pointerInput
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull() ?: continue
|
||||
|
||||
when {
|
||||
change.pressed && !helper.movingHandle -> {
|
||||
val x = change.position.x
|
||||
val y = change.position.y
|
||||
val startRect = Rect(
|
||||
helper.startHandleX - handleSizePx / 2 - handleInsetPx,
|
||||
helper.startHandleY - handleInsetPx,
|
||||
helper.startHandleX + handleSizePx / 2 + handleInsetPx,
|
||||
helper.startHandleY + handleSizePx + handleInsetPx
|
||||
)
|
||||
val endRect = Rect(
|
||||
helper.endHandleX - handleSizePx / 2 - handleInsetPx,
|
||||
helper.endHandleY - handleInsetPx,
|
||||
helper.endHandleX + handleSizePx / 2 + handleInsetPx,
|
||||
helper.endHandleY + handleSizePx + handleInsetPx
|
||||
)
|
||||
when {
|
||||
startRect.contains(Offset(x, y)) -> {
|
||||
helper.beginHandleDrag(isStart = true, x, y)
|
||||
helper.hideFloatingToolbar()
|
||||
change.consume()
|
||||
}
|
||||
endRect.contains(Offset(x, y)) -> {
|
||||
helper.beginHandleDrag(isStart = false, x, y)
|
||||
helper.hideFloatingToolbar()
|
||||
change.consume()
|
||||
}
|
||||
else -> {
|
||||
helper.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
change.pressed && helper.movingHandle -> {
|
||||
helper.moveHandle(change.position.x, change.position.y)
|
||||
helper.showMagnifier(change.position.x, change.position.y)
|
||||
change.consume()
|
||||
}
|
||||
!change.pressed && helper.movingHandle -> {
|
||||
helper.hideMagnifier()
|
||||
helper.endHandleDrag()
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val info = helper.layoutInfo ?: return@Canvas
|
||||
val layout = info.layout
|
||||
val text = info.text
|
||||
|
||||
// Convert window coords to overlay-local coords
|
||||
val offsetX = info.windowX - helper.overlayWindowX
|
||||
val offsetY = info.windowY - helper.overlayWindowY
|
||||
|
||||
val startOffset = helper.selectionStart.coerceIn(0, text.length)
|
||||
val endOffset = helper.selectionEnd.coerceIn(0, text.length)
|
||||
if (startOffset >= endOffset) return@Canvas
|
||||
|
||||
val startLine = layout.getLineForOffset(startOffset)
|
||||
val endLine = layout.getLineForOffset(endOffset)
|
||||
|
||||
for (line in startLine..endLine) {
|
||||
val lineTop = layout.getLineTop(line).toFloat() + offsetY
|
||||
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY
|
||||
val left = if (line == startLine) {
|
||||
layout.getPrimaryHorizontal(startOffset) + offsetX
|
||||
} else {
|
||||
layout.getLineLeft(line) + offsetX
|
||||
}
|
||||
val right = if (line == endLine) {
|
||||
layout.getPrimaryHorizontal(endOffset) + offsetX
|
||||
} else {
|
||||
layout.getLineRight(line) + offsetX
|
||||
}
|
||||
drawRoundRect(
|
||||
color = HighlightColor,
|
||||
topLeft = Offset(left, lineTop),
|
||||
size = Size(right - left, lineBottom - lineTop),
|
||||
cornerRadius = CornerRadius(highlightCornerPx)
|
||||
)
|
||||
}
|
||||
|
||||
val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX
|
||||
val startHy = layout.getLineBottom(startLine).toFloat() + offsetY
|
||||
val endHx = layout.getPrimaryHorizontal(endOffset) + offsetX
|
||||
val endHy = layout.getLineBottom(endLine).toFloat() + offsetY
|
||||
|
||||
helper.startHandleX = startHx
|
||||
helper.startHandleY = startHy
|
||||
helper.endHandleX = endHx
|
||||
helper.endHandleY = endHy
|
||||
|
||||
drawStartHandle(startHx, startHy, handleSizePx)
|
||||
drawEndHandle(endHx, endHy, handleSizePx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float) {
|
||||
val half = size / 2f
|
||||
drawCircle(
|
||||
color = HandleColor,
|
||||
radius = half,
|
||||
center = Offset(x, y + half)
|
||||
)
|
||||
drawRect(
|
||||
color = HandleColor,
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(half, half)
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float) {
|
||||
val half = size / 2f
|
||||
drawCircle(
|
||||
color = HandleColor,
|
||||
radius = half,
|
||||
center = Offset(x, y + half)
|
||||
)
|
||||
drawRect(
|
||||
color = HandleColor,
|
||||
topLeft = Offset(x - half, y),
|
||||
size = Size(half, half)
|
||||
)
|
||||
}
|
||||
@@ -558,6 +558,10 @@ fun AppleEmojiText(
|
||||
onClickableSpanPressStart: (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
||||
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
||||
onTextLongPress: ((touchX: Int, touchY: Int) -> Unit)? = null,
|
||||
onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null,
|
||||
onSelectionDragEnd: (() -> Unit)? = null,
|
||||
onViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiTextView) -> Unit)? = null,
|
||||
minHeightMultiplier: Float = 1.5f
|
||||
) {
|
||||
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
||||
@@ -601,6 +605,10 @@ fun AppleEmojiText(
|
||||
enableMentionHighlight(enableMentions)
|
||||
setOnMentionClickListener(onMentionClick)
|
||||
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||
onTextLongPressCallback = onTextLongPress
|
||||
this.onSelectionDrag = onSelectionDrag
|
||||
this.onSelectionDragEnd = onSelectionDragEnd
|
||||
onViewCreated?.invoke(this)
|
||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||
val canUseTextViewClick = !enableLinks
|
||||
setOnClickListener(
|
||||
@@ -634,6 +642,9 @@ fun AppleEmojiText(
|
||||
view.enableMentionHighlight(enableMentions)
|
||||
view.setOnMentionClickListener(onMentionClick)
|
||||
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||
view.onTextLongPressCallback = onTextLongPress
|
||||
view.onSelectionDrag = onSelectionDrag
|
||||
view.onSelectionDragEnd = onSelectionDragEnd
|
||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||
val canUseTextViewClick = !enableLinks
|
||||
view.setOnClickListener(
|
||||
@@ -695,13 +706,23 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
|
||||
// 🔥 Long press callback для selection в MessageBubble
|
||||
var onLongClickCallback: (() -> Unit)? = null
|
||||
var onTextLongPressCallback: ((touchX: Int, touchY: Int) -> Unit)? = null
|
||||
// Telegram flow: forward drag/up events after long press fires
|
||||
var onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null
|
||||
var onSelectionDragEnd: (() -> Unit)? = null
|
||||
private var downOnClickableSpan: Boolean = false
|
||||
private var suppressPerformClickOnce: Boolean = false
|
||||
private var selectionDragActive: Boolean = false
|
||||
|
||||
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
if (!downOnClickableSpan) {
|
||||
if (downOnClickableSpan) return
|
||||
if (onTextLongPressCallback != null) {
|
||||
onTextLongPressCallback?.invoke(e.rawX.toInt(), e.rawY.toInt())
|
||||
selectionDragActive = true
|
||||
parent?.requestDisallowInterceptTouchEvent(true) // block scroll during drag
|
||||
} else {
|
||||
onLongClickCallback?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -721,21 +742,33 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downOnClickableSpan = isTouchOnClickableSpan(event)
|
||||
suppressPerformClickOnce = downOnClickableSpan
|
||||
selectionDragActive = false
|
||||
if (downOnClickableSpan) {
|
||||
clickableSpanPressStartCallback?.invoke()
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (selectionDragActive) {
|
||||
onSelectionDrag?.invoke(event.rawX.toInt(), event.rawY.toInt())
|
||||
return true
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (selectionDragActive) {
|
||||
selectionDragActive = false
|
||||
onSelectionDragEnd?.invoke()
|
||||
downOnClickableSpan = false
|
||||
parent?.requestDisallowInterceptTouchEvent(false)
|
||||
return true
|
||||
}
|
||||
downOnClickableSpan = false
|
||||
parent?.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Позволяем GestureDetector обработать событие (для long press)
|
||||
gestureDetector.onTouchEvent(event)
|
||||
// Передаем событие дальше для обработки ссылок
|
||||
return super.dispatchTouchEvent(event)
|
||||
}
|
||||
|
||||
@@ -822,6 +855,18 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun getLayoutInfo(): com.rosetta.messenger.ui.chats.components.LayoutInfo? {
|
||||
val l = layout ?: return null
|
||||
val loc = IntArray(2)
|
||||
getLocationInWindow(loc)
|
||||
return com.rosetta.messenger.ui.chats.components.LayoutInfo(
|
||||
layout = l,
|
||||
windowX = loc[0] + totalPaddingLeft,
|
||||
windowY = loc[1] + totalPaddingTop,
|
||||
text = text ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
fun setTextWithEmojis(text: String) {
|
||||
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
|
||||
val processMentions = mentionsEnabled && !isLargeText
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class TextSelectionHelperTest {
|
||||
|
||||
private lateinit var helper: TextSelectionHelper
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
helper = TextSelectionHelper()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is not active`() {
|
||||
assertFalse(helper.isActive)
|
||||
assertFalse(helper.isInSelectionMode)
|
||||
assertEquals(-1, helper.selectionStart)
|
||||
assertEquals(-1, helper.selectionEnd)
|
||||
assertNull(helper.selectedMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear resets all state`() {
|
||||
helper.clear()
|
||||
assertFalse(helper.isActive)
|
||||
assertEquals(-1, helper.selectionStart)
|
||||
assertEquals(-1, helper.selectionEnd)
|
||||
assertNull(helper.selectedMessageId)
|
||||
assertNull(helper.layoutInfo)
|
||||
assertFalse(helper.showToolbar)
|
||||
assertFalse(helper.movingHandle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSelectedText returns null when not active`() {
|
||||
assertNull(helper.getSelectedText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateSelectionEnd does not change when not active`() {
|
||||
helper.updateSelectionEnd(5)
|
||||
assertEquals(-1, helper.selectionEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateSelectionStart does not change when not active`() {
|
||||
helper.updateSelectionStart(0)
|
||||
assertEquals(-1, helper.selectionStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCharOffsetFromCoords returns -1 when no layout`() {
|
||||
assertEquals(-1, helper.getCharOffsetFromCoords(100, 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectAll does nothing when no layout`() {
|
||||
helper.selectAll()
|
||||
assertEquals(-1, helper.selectionStart)
|
||||
assertEquals(-1, helper.selectionEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moveHandle does nothing when not moving`() {
|
||||
helper.moveHandle(100f, 100f)
|
||||
assertFalse(helper.movingHandle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `endHandleDrag sets movingHandle to false`() {
|
||||
helper.endHandleDrag()
|
||||
assertFalse(helper.movingHandle)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user