Выделение текста — selection mode, handles, toolbar, magnifier
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user