Выделение текста — 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 -> { else -> {
LazyColumn( LazyColumn(
state = listState, state = listState,
userScrollEnabled = !textSelectionHelper.movingHandle,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.nestedScroll( .nestedScroll(

View File

@@ -402,6 +402,9 @@ fun MessageBubble(
else Color(0xFF2196F3) // Стандартный Material Blue для входящих else Color(0xFF2196F3) // Стандартный Material Blue для входящих
} }
var textViewRef by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiTextView?>(null) } 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 linksEnabled = !isSelectionMode
val textClickHandler: (() -> Unit)? = onClick val textClickHandler: (() -> Unit)? = onClick
val mentionClickHandler: ((String) -> Unit)? = val mentionClickHandler: ((String) -> Unit)? =
@@ -1070,7 +1073,7 @@ fun MessageBubble(
onLongClick = onLongClick =
onLongClick, // 🔥 Long press для selection onLongClick, // 🔥 Long press для selection
onViewCreated = { textViewRef = it }, onViewCreated = { textViewRef = it },
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY -> onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
val info = textViewRef?.getLayoutInfo() val info = textViewRef?.getLayoutInfo()
if (info != null) { if (info != null) {
textSelectionHelper.startSelection( textSelectionHelper.startSelection(
@@ -1081,7 +1084,18 @@ fun MessageBubble(
view = textViewRef 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 = { timeContent = {
@@ -1287,7 +1301,7 @@ fun MessageBubble(
onLongClick = onLongClick =
onLongClick, // 🔥 Long press для selection onLongClick, // 🔥 Long press для selection
onViewCreated = { textViewRef = it }, onViewCreated = { textViewRef = it },
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY -> onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
val info = textViewRef?.getLayoutInfo() val info = textViewRef?.getLayoutInfo()
if (info != null) { if (info != null) {
textSelectionHelper.startSelection( textSelectionHelper.startSelection(
@@ -1298,7 +1312,18 @@ fun MessageBubble(
view = textViewRef 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 = { timeContent = {

View File

@@ -72,6 +72,11 @@ class TextSelectionHelper {
private set private set
var movingHandleStart by mutableStateOf(false) var movingHandleStart by mutableStateOf(false)
private set 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 movingOffsetX = 0f
private var movingOffsetY = 0f private var movingOffsetY = 0f
@@ -117,6 +122,13 @@ class TextSelectionHelper {
isActive = true isActive = true
handleViewProgress = 1f 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) view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
showToolbar = false showToolbar = false
} }
@@ -161,11 +173,27 @@ class TextSelectionHelper {
val y = (touchY + movingOffsetY).toInt() val y = (touchY + movingOffsetY).toInt()
val offset = getCharOffsetFromCoords(x, y) val offset = getCharOffsetFromCoords(x, y)
if (offset < 0) return 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) if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset)
} }
fun endHandleDrag() { fun endHandleDrag() {
movingHandle = false movingHandle = false
movingDirectionSettling = false
isOneTouch = false
showFloatingToolbar() showFloatingToolbar()
} }
@@ -272,6 +300,8 @@ class TextSelectionHelper {
isActive = false isActive = false
handleViewProgress = 0f handleViewProgress = 0f
movingHandle = false movingHandle = false
movingDirectionSettling = false
isOneTouch = false
showToolbar = false showToolbar = false
hideMagnifier() hideMagnifier()
} }
@@ -301,12 +331,20 @@ private fun FloatingToolbarPopup(
val info = helper.layoutInfo ?: return val info = helper.layoutInfo ?: return
val layout = info.layout val layout = info.layout
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length)) 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( Popup(
alignment = Alignment.TopStart, alignment = Alignment.TopStart,
offset = IntOffset(toolbarX.toInt(), toolbarY) offset = IntOffset(toolbarX.toInt(), toolbarY.toInt())
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -559,6 +559,8 @@ fun AppleEmojiText(
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble) onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble) onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
onTextLongPress: ((touchX: Int, touchY: Int) -> Unit)? = null, 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, onViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiTextView) -> Unit)? = null,
minHeightMultiplier: Float = 1.5f minHeightMultiplier: Float = 1.5f
) { ) {
@@ -604,6 +606,8 @@ fun AppleEmojiText(
setOnMentionClickListener(onMentionClick) setOnMentionClickListener(onMentionClick)
setOnClickableSpanPressStartListener(onClickableSpanPressStart) setOnClickableSpanPressStartListener(onClickableSpanPressStart)
onTextLongPressCallback = onTextLongPress onTextLongPressCallback = onTextLongPress
this.onSelectionDrag = onSelectionDrag
this.onSelectionDragEnd = onSelectionDragEnd
onViewCreated?.invoke(this) onViewCreated?.invoke(this)
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap. // In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
val canUseTextViewClick = !enableLinks val canUseTextViewClick = !enableLinks
@@ -639,6 +643,8 @@ fun AppleEmojiText(
view.setOnMentionClickListener(onMentionClick) view.setOnMentionClickListener(onMentionClick)
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart) view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
view.onTextLongPressCallback = onTextLongPress view.onTextLongPressCallback = onTextLongPress
view.onSelectionDrag = onSelectionDrag
view.onSelectionDragEnd = onSelectionDragEnd
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap. // In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
val canUseTextViewClick = !enableLinks val canUseTextViewClick = !enableLinks
view.setOnClickListener( view.setOnClickListener(
@@ -701,8 +707,12 @@ class AppleEmojiTextView @JvmOverloads constructor(
// 🔥 Long press callback для selection в MessageBubble // 🔥 Long press callback для selection в MessageBubble
var onLongClickCallback: (() -> Unit)? = null var onLongClickCallback: (() -> Unit)? = null
var onTextLongPressCallback: ((touchX: Int, touchY: Int) -> 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 downOnClickableSpan: Boolean = false
private var suppressPerformClickOnce: Boolean = false private var suppressPerformClickOnce: Boolean = false
private var selectionDragActive: Boolean = false
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod // 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
@@ -710,6 +720,8 @@ class AppleEmojiTextView @JvmOverloads constructor(
if (downOnClickableSpan) return if (downOnClickableSpan) return
if (onTextLongPressCallback != null) { if (onTextLongPressCallback != null) {
onTextLongPressCallback?.invoke(e.rawX.toInt(), e.rawY.toInt()) onTextLongPressCallback?.invoke(e.rawX.toInt(), e.rawY.toInt())
selectionDragActive = true
parent?.requestDisallowInterceptTouchEvent(true) // block scroll during drag
} else { } else {
onLongClickCallback?.invoke() onLongClickCallback?.invoke()
} }
@@ -730,21 +742,33 @@ class AppleEmojiTextView @JvmOverloads constructor(
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
downOnClickableSpan = isTouchOnClickableSpan(event) downOnClickableSpan = isTouchOnClickableSpan(event)
suppressPerformClickOnce = downOnClickableSpan suppressPerformClickOnce = downOnClickableSpan
selectionDragActive = false
if (downOnClickableSpan) { if (downOnClickableSpan) {
clickableSpanPressStartCallback?.invoke() clickableSpanPressStartCallback?.invoke()
parent?.requestDisallowInterceptTouchEvent(true) parent?.requestDisallowInterceptTouchEvent(true)
} }
} }
MotionEvent.ACTION_MOVE -> {
if (selectionDragActive) {
onSelectionDrag?.invoke(event.rawX.toInt(), event.rawY.toInt())
return true
}
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
if (selectionDragActive) {
selectionDragActive = false
onSelectionDragEnd?.invoke()
downOnClickableSpan = false
parent?.requestDisallowInterceptTouchEvent(false)
return true
}
downOnClickableSpan = false downOnClickableSpan = false
parent?.requestDisallowInterceptTouchEvent(false) parent?.requestDisallowInterceptTouchEvent(false)
} }
} }
// Позволяем GestureDetector обработать событие (для long press)
gestureDetector.onTouchEvent(event) gestureDetector.onTouchEvent(event)
// Передаем событие дальше для обработки ссылок
return super.dispatchTouchEvent(event) return super.dispatchTouchEvent(event)
} }