feat: добавить TextSelectionOverlay — highlight, handles, drag interaction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 15:03:35 +05:00
parent 988896c080
commit 419761e34d

View File

@@ -7,11 +7,25 @@ import android.text.Layout
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
data class LayoutInfo( data class LayoutInfo(
val layout: Layout, val layout: Layout,
@@ -34,6 +48,17 @@ class TextSelectionHelper {
private set private set
var handleViewProgress by mutableFloatStateOf(0f) var handleViewProgress by mutableFloatStateOf(0f)
private set private set
var movingHandle by mutableStateOf(false)
private set
var movingHandleStart by mutableStateOf(false)
private set
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)
val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart
@@ -89,6 +114,26 @@ class TextSelectionHelper {
selectionEnd = newEnd selectionEnd = newEnd
} }
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
if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset)
}
fun endHandleDrag() {
movingHandle = false
}
fun getCharOffsetFromCoords(x: Int, y: Int): Int { fun getCharOffsetFromCoords(x: Int, y: Int): Int {
val info = layoutInfo ?: return -1 val info = layoutInfo ?: return -1
val localX = x - info.windowX val localX = x - info.windowX
@@ -128,5 +173,154 @@ class TextSelectionHelper {
layoutInfo = null layoutInfo = null
isActive = false isActive = false
handleViewProgress = 0f handleViewProgress = 0f
movingHandle = false
} }
} }
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
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()) {
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)
change.consume()
}
endRect.contains(Offset(x, y)) -> {
helper.beginHandleDrag(isStart = false, x, y)
change.consume()
}
else -> {
helper.clear()
}
}
}
change.pressed && helper.movingHandle -> {
helper.moveHandle(change.position.x, change.position.y)
change.consume()
}
!change.pressed && helper.movingHandle -> {
helper.endHandleDrag()
change.consume()
}
}
}
}
}
) {
val info = helper.layoutInfo ?: return@Canvas
val layout = info.layout
val text = info.text
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() + info.windowY
val lineBottom = layout.getLineBottom(line).toFloat() + info.windowY
val left = if (line == startLine) {
layout.getPrimaryHorizontal(startOffset) + info.windowX
} else {
layout.getLineLeft(line) + info.windowX
}
val right = if (line == endLine) {
layout.getPrimaryHorizontal(endOffset) + info.windowX
} else {
layout.getLineRight(line) + info.windowX
}
drawRoundRect(
color = HighlightColor,
topLeft = Offset(left, lineTop),
size = Size(right - left, lineBottom - lineTop),
cornerRadius = CornerRadius(highlightCornerPx)
)
}
val startHx = layout.getPrimaryHorizontal(startOffset) + info.windowX
val startHy = layout.getLineBottom(startLine).toFloat() + info.windowY
val endHx = layout.getPrimaryHorizontal(endOffset) + info.windowX
val endHy = layout.getLineBottom(endLine).toFloat() + info.windowY
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)
)
}