Выделение текста — selection mode, handles, toolbar, magnifier

This commit is contained in:
2026-04-12 18:37:38 +05:00
parent 9fe5f35923
commit ad08af7f0c
4 changed files with 97 additions and 9 deletions

View File

@@ -3037,6 +3037,7 @@ fun ChatDetailScreen(
else -> {
LazyColumn(
state = listState,
userScrollEnabled = !textSelectionHelper.movingHandle,
modifier =
Modifier.fillMaxSize()
.nestedScroll(

View File

@@ -402,6 +402,9 @@ fun MessageBubble(
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)? =
@@ -1070,7 +1073,7 @@ fun MessageBubble(
onLongClick =
onLongClick, // 🔥 Long press для selection
onViewCreated = { textViewRef = it },
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY ->
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
val info = textViewRef?.getLayoutInfo()
if (info != null) {
textSelectionHelper.startSelection(
@@ -1081,7 +1084,18 @@ fun MessageBubble(
view = textViewRef
)
}
} else null
} 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 = {
@@ -1287,7 +1301,7 @@ fun MessageBubble(
onLongClick =
onLongClick, // 🔥 Long press для selection
onViewCreated = { textViewRef = it },
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY ->
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
val info = textViewRef?.getLayoutInfo()
if (info != null) {
textSelectionHelper.startSelection(
@@ -1298,7 +1312,18 @@ fun MessageBubble(
view = textViewRef
)
}
} else null
} 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

@@ -72,6 +72,11 @@ class TextSelectionHelper {
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
@@ -117,6 +122,13 @@ class TextSelectionHelper {
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
}
@@ -161,11 +173,27 @@ class TextSelectionHelper {
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()
}
@@ -272,6 +300,8 @@ class TextSelectionHelper {
isActive = false
handleViewProgress = 0f
movingHandle = false
movingDirectionSettling = false
isOneTouch = false
showToolbar = false
hideMagnifier()
}
@@ -301,12 +331,20 @@ private fun FloatingToolbarPopup(
val info = helper.layoutInfo ?: return
val layout = info.layout
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
val toolbarY = (layout.getLineTop(startLine) + info.windowY - 52).coerceAtLeast(0)
val toolbarX = ((helper.startHandleX + helper.endHandleX) / 2f - 80f).coerceAtLeast(0f)
// 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)
offset = IntOffset(toolbarX.toInt(), toolbarY.toInt())
) {
Row(
modifier = Modifier

View File

@@ -559,6 +559,8 @@ fun AppleEmojiText(
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
) {
@@ -604,6 +606,8 @@ fun AppleEmojiText(
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
@@ -639,6 +643,8 @@ fun AppleEmojiText(
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(
@@ -701,8 +707,12 @@ 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() {
@@ -710,6 +720,8 @@ class AppleEmojiTextView @JvmOverloads constructor(
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()
}
@@ -730,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)
}