mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-26 02:24:54 +00:00
copy actual message text with links
This commit is contained in:
+66
-8
@@ -35,7 +35,7 @@ import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.item.displayText
|
||||
import chat.simplex.common.views.chat.item.itemSegmentDisplayText
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
@@ -110,6 +110,25 @@ class SelectionManager {
|
||||
selectionState = SelectionState.Selected
|
||||
}
|
||||
|
||||
// Snaps boundary offsets to include full transformed segments (mentions, links with showText).
|
||||
fun snapSelection(items: List<MergedItem>, linkMode: SimplexLinkMode) {
|
||||
val r = range ?: return
|
||||
val startCi = items.getOrNull(r.startIndex)?.newest()?.item
|
||||
val endCi = items.getOrNull(r.endIndex)?.newest()?.item
|
||||
// expandRight: snap in the direction that grows the selection
|
||||
val startExpandRight = if (r.startIndex == r.endIndex) r.startOffset > r.endOffset else r.startIndex < r.endIndex
|
||||
val endExpandRight = if (r.startIndex == r.endIndex) r.endOffset > r.startOffset else r.endIndex < r.startIndex
|
||||
val snappedStart = if (startCi != null && r.startOffset >= 0)
|
||||
snapOffset(startCi, r.startOffset, linkMode, expandRight = startExpandRight)
|
||||
else r.startOffset
|
||||
val snappedEnd = if (endCi != null && r.endOffset >= 0)
|
||||
snapOffset(endCi, r.endOffset, linkMode, expandRight = endExpandRight)
|
||||
else r.endOffset
|
||||
if (snappedStart != r.startOffset || snappedEnd != r.endOffset) {
|
||||
range = r.copy(startOffset = snappedStart, endOffset = snappedEnd)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
range = null
|
||||
selectionState = SelectionState.Idle
|
||||
@@ -176,18 +195,14 @@ class SelectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun getSelectedText(items: List<MergedItem>, linkMode: SimplexLinkMode): String {
|
||||
fun getSelectedCopiedText(items: List<MergedItem>, linkMode: SimplexLinkMode): String {
|
||||
val r = range ?: return ""
|
||||
val lo = minOf(r.startIndex, r.endIndex)
|
||||
val hi = maxOf(r.startIndex, r.endIndex)
|
||||
return (lo..hi).mapNotNull { idx ->
|
||||
val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null
|
||||
val sel = selectedRange(range, idx) ?: return@mapNotNull null
|
||||
val text = displayText(ci, linkMode, sendCommandMsg = false)
|
||||
text.substring(
|
||||
sel.first.coerceAtMost(text.length),
|
||||
(sel.last + 1).coerceAtMost(text.length)
|
||||
)
|
||||
selectedItemCopiedText(ci, sel, linkMode)
|
||||
}.reversed().joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -222,6 +237,48 @@ fun selectedRange(range: SelectionRange?, index: Int): IntRange? {
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts source text for the selected range within one item.
|
||||
// Selection offsets are in display-text space. For transformed segments (mentions, links with showText),
|
||||
// the full source is emitted if any part is selected. For untransformed segments, partial substring works.
|
||||
private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String {
|
||||
val formattedText = ci.formattedText ?: return ci.text.substring(
|
||||
sel.first.coerceAtMost(ci.text.length),
|
||||
(sel.last + 1).coerceAtMost(ci.text.length)
|
||||
)
|
||||
val sb = StringBuilder()
|
||||
var displayOffset = 0
|
||||
for (ft in formattedText) {
|
||||
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
|
||||
val displayEnd = displayOffset + segDisplay.length
|
||||
val overlapStart = maxOf(displayOffset, sel.first)
|
||||
val overlapEnd = minOf(displayEnd, sel.last + 1)
|
||||
if (overlapStart < overlapEnd) {
|
||||
if (ft.text.length == segDisplay.length) {
|
||||
sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset)
|
||||
} else {
|
||||
sb.append(ft.text)
|
||||
}
|
||||
}
|
||||
displayOffset = displayEnd
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
// Snaps a boundary offset to include full transformed segments.
|
||||
private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int {
|
||||
val formattedText = ci.formattedText ?: return offset
|
||||
var displayOffset = 0
|
||||
for (ft in formattedText) {
|
||||
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
|
||||
val displayEnd = displayOffset + segDisplay.length
|
||||
if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) {
|
||||
return if (expandRight) displayEnd else displayOffset
|
||||
}
|
||||
displayOffset = displayEnd
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
val LocalSelectionManager = staticCompositionLocalOf<SelectionManager?> { null }
|
||||
|
||||
private const val AUTO_SCROLL_ZONE_PX = 40f
|
||||
@@ -253,7 +310,7 @@ fun BoxScope.SelectionHandler(
|
||||
|
||||
manager.listState = listState
|
||||
manager.onCopySelection = {
|
||||
clipboard.setText(AnnotatedString(manager.getSelectedText(mergedItems.value.items, linkMode)))
|
||||
clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode)))
|
||||
manager.clearSelection()
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}
|
||||
@@ -314,6 +371,7 @@ fun BoxScope.SelectionHandler(
|
||||
SelectionState.Selecting -> {
|
||||
if (!event.pressed) {
|
||||
manager.endSelection()
|
||||
manager.snapSelection(mergedItems.value.items, linkMode)
|
||||
return@awaitEachGesture
|
||||
}
|
||||
val windowPos = event.position + manager.viewportPosition
|
||||
|
||||
+26
-26
@@ -57,35 +57,35 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString =
|
||||
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
|
||||
AnnotatedString(".", SpanStyle(fontWeight = w))
|
||||
|
||||
// Must be coordinated with MarkdownText — same text transformations for:
|
||||
// Mention, HyperLink, SimplexLink, Command
|
||||
fun displayText(ci: ChatItem, linkMode: SimplexLinkMode, sendCommandMsg: Boolean): String {
|
||||
val formattedText = ci.formattedText
|
||||
if (formattedText == null) return ci.text
|
||||
return formattedText.joinToString("") { ft ->
|
||||
when (ft.format) {
|
||||
is Format.Mention -> {
|
||||
val mention = ci.mentions?.get(ft.format.memberName)
|
||||
if (mention?.memberRef != null) {
|
||||
val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName
|
||||
else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})"
|
||||
mentionText(name)
|
||||
} else if (mention != null) mentionText(ft.format.memberName)
|
||||
else ft.text
|
||||
}
|
||||
is Format.HyperLink -> ft.format.showText ?: ft.text
|
||||
is Format.SimplexLink -> {
|
||||
val t = ft.format.showText
|
||||
?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null
|
||||
if (t != null) "$t ${ft.format.viaHosts}" else ft.text
|
||||
}
|
||||
is Format.Command -> if (sendCommandMsg) "/${ft.format.commandStr}" else ft.text
|
||||
else -> ft.text
|
||||
// Display text for a single formatted segment — must be coordinated with MarkdownText.
|
||||
fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String =
|
||||
when (ft.format) {
|
||||
is Format.Mention -> {
|
||||
val mention = ci.mentions?.get(ft.format.memberName)
|
||||
if (mention?.memberRef != null) {
|
||||
val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName
|
||||
else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})"
|
||||
mentionText(name)
|
||||
} else if (mention != null) mentionText(ft.format.memberName)
|
||||
else ft.text
|
||||
}
|
||||
is Format.HyperLink -> ft.format.showText ?: ft.text
|
||||
is Format.SimplexLink -> {
|
||||
val t = ft.format.showText
|
||||
?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null
|
||||
if (t != null) "$t ${ft.format.viaHosts}" else ft.text
|
||||
}
|
||||
is Format.Command -> ft.text
|
||||
else -> ft.text
|
||||
}
|
||||
|
||||
// Full display text for a chat item — joins segment display texts.
|
||||
fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String {
|
||||
val formattedText = ci.formattedText ?: return ci.text
|
||||
return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) }
|
||||
}
|
||||
|
||||
// Text transformations in this function must match displayText above
|
||||
// Text transformations in MarkdownText must match itemSegmentDisplayText above
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
text: CharSequence,
|
||||
@@ -531,4 +531,4 @@ private fun isRtl(s: CharSequence): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name"
|
||||
fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name"
|
||||
|
||||
Reference in New Issue
Block a user