Compare commits

...

10 Commits

Author SHA1 Message Date
ad08af7f0c Выделение текста — selection mode, handles, toolbar, magnifier 2026-04-12 18:37:38 +05:00
9fe5f35923 fix: посимвольное выделение + magnifier на позиции handle + haptic на каждый символ
Было: word snap при drag handle → нельзя выделить часть слова
Стало: посимвольно при drag (word snap только при первом long press)

Magnifier: показывается на позиции handle (текущий символ),
а не на позиции пальца. По Y — центр строки текста.

Haptic: TEXT_HANDLE_MOVE на каждый символ (не на каждое слово).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:17:32 +05:00
78925dd61d fix: magnifier правильные координаты + haptic при изменении выделения
Magnifier:
- Конвертация overlay-local → view-local координаты для Magnifier.show()
- Builder: 240×64px, cornerRadius 12, elevation 4, offset -80 (над текстом)

Haptic:
- TEXT_HANDLE_MOVE при каждом изменении selectionStart/selectionEnd
- Как в Telegram: вибрация при перемещении handle по словам

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:12:29 +05:00
1ac3d93f74 fix: правильные координаты text selection — window→overlay-local конвертация
Root cause: overlay Canvas рисует в локальных координатах, но LayoutInfo
возвращает позицию в window coordinates. Разница = position status bar,
toolbar, и parent padding → highlight смещался вниз.

Фикс:
- onGloballyPositioned на overlay Box → знаем overlayWindowX/Y
- Canvas: offsetX/Y = info.windowX - overlayWindowX (window→local)
- getCharOffsetFromCoords: overlay-local → text-local через ту же delta
- Handle positions теперь в overlay-local координатах → drag работает

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:08:12 +05:00
6ad24974e0 feat: magnifier view setup + unit тесты для TextSelectionHelper
- setMagnifierView(view) в ChatDetailScreen через LaunchedEffect
- 9 unit тестов: initial state, clear, getSelectedText, boundary checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:15:50 +05:00
e825a1ef30 feat: добавить floating toolbar (Copy/Select All) и Magnifier (API 28+) для text selection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:14:30 +05:00
7fcf1195e1 feat: интегрировать TextSelectionHelper в ChatDetailScreen и MessageBubble
- TextSelectionHelper инстанс в ChatDetailScreen
- TextSelectionOverlay поверх LazyColumn
- Clear selection при scroll и при message selection mode
- onTextLongPress + onViewCreated проброшены через MessageBubble к AppleEmojiText

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:12:16 +05:00
a10482b794 feat: добавить onTextLongPress callback и getLayoutInfo() в AppleEmojiTextView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:05:25 +05:00
419761e34d feat: добавить TextSelectionOverlay — highlight, handles, drag interaction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:03:35 +05:00
988896c080 feat: добавить TextSelectionHelper — core state, word snap, char offset
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:44:46 +05:00
5 changed files with 758 additions and 15 deletions

View File

@@ -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()
)
}
}
}

View File

@@ -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 = {

View File

@@ -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)
)
}

View File

@@ -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

View File

@@ -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)
}
}