Rich bubble: text selection in context-preview mode

Adds drag-handle text selection driven by TextSelectionNode. Exposes
attributedString and selection helpers on InstantPage text items, and
introduces a multi-text adapter aggregating items as a TextNodeProtocol.
Gates selection actions on reply-options and fixes highlight z-order.
This commit is contained in:
isaac 2026-05-01 21:06:25 +02:00
parent fdb2f369ec
commit 972cdf0658
6 changed files with 1020 additions and 13 deletions

View file

@ -0,0 +1,103 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol {
private struct Entry {
let item: InstantPageTextItem
let charOffset: Int
let frameOrigin: CGPoint
}
private let entries: [Entry]
private let combinedString: NSAttributedString
public init(items: [InstantPageTextItem]) {
let separator = NSAttributedString(string: "\n\n")
let combined = NSMutableAttributedString()
var entries: [Entry] = []
for (index, item) in items.enumerated() {
let charOffset = combined.length
entries.append(Entry(item: item, charOffset: charOffset, frameOrigin: item.frame.origin))
combined.append(item.attributedString)
if index != items.count - 1 {
combined.append(separator)
}
}
self.entries = entries
self.combinedString = combined
super.init()
self.isUserInteractionEnabled = false
}
public var currentText: NSAttributedString? {
return self.combinedString
}
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
for entry in self.entries {
let localPoint = CGPoint(x: point.x - entry.frameOrigin.x, y: point.y - entry.frameOrigin.y)
if let (localIndex, attrs) = entry.item.attributesAtPoint(localPoint, orNearest: false) {
return (entry.charOffset + localIndex, attrs)
}
}
guard orNearest, !self.entries.isEmpty else {
return nil
}
var nearestEntry = self.entries[0]
var nearestDistance = CGFloat.greatestFiniteMagnitude
for entry in self.entries {
let frame = CGRect(origin: entry.frameOrigin, size: entry.item.frame.size)
let distance: CGFloat
if point.y < frame.minY {
distance = frame.minY - point.y
} else if point.y > frame.maxY {
distance = point.y - frame.maxY
} else {
distance = 0.0
}
if distance < nearestDistance {
nearestDistance = distance
nearestEntry = entry
}
}
let localPoint = CGPoint(x: point.x - nearestEntry.frameOrigin.x, y: point.y - nearestEntry.frameOrigin.y)
if let (localIndex, attrs) = nearestEntry.item.attributesAtPoint(localPoint, orNearest: true) {
return (nearestEntry.charOffset + localIndex, attrs)
}
return nil
}
public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? {
var allRects: [CGRect] = []
var startEdge: TextRangeRectEdge?
var endEdge: TextRangeRectEdge?
for entry in self.entries {
let itemLength = entry.item.attributedString.length
let entryRange = NSRange(location: entry.charOffset, length: itemLength)
let intersection = NSIntersectionRange(range, entryRange)
if intersection.length == 0 {
continue
}
let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length)
guard let result = entry.item.textRangeRects(in: localRange) else {
continue
}
for rect in result.rects {
allRects.append(rect.offsetBy(dx: entry.frameOrigin.x, dy: entry.frameOrigin.y))
}
let translatedStart = TextRangeRectEdge(x: result.start.x + entry.frameOrigin.x, y: result.start.y + entry.frameOrigin.y, height: result.start.height)
let translatedEnd = TextRangeRectEdge(x: result.end.x + entry.frameOrigin.x, y: result.end.y + entry.frameOrigin.y, height: result.end.height)
if startEdge == nil {
startEdge = translatedStart
}
endEdge = translatedEnd
}
guard !allRects.isEmpty, let start = startEdge, let end = endEdge else {
return nil
}
return (allRects, start, end)
}
}

View file

@ -175,7 +175,7 @@ private func attachmentBoundsForRange(_ range: NSRange, line: InstantPageTextLin
}
public final class InstantPageTextItem: InstantPageItem {
let attributedString: NSAttributedString
public let attributedString: NSAttributedString
public let lines: [InstantPageTextLine]
let rtlLineIndices: Set<Int>
public var frame: CGRect
@ -300,7 +300,7 @@ public final class InstantPageTextItem: InstantPageItem {
let boundsWidth = self.frame.width
for i in 0 ..< self.lines.count {
let line = self.lines[i]
let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment)
if lineFrame.insetBy(dx: -5.0, dy: -5.0).contains(transformedPoint) {
var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY))
@ -321,7 +321,53 @@ public final class InstantPageTextItem: InstantPageItem {
}
return nil
}
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
if let direct = self.attributesAtPoint(point) {
return direct
}
guard orNearest, !self.lines.isEmpty else {
return nil
}
let boundsWidth = self.frame.width
var nearestLineIndex = 0
var nearestDistance = CGFloat.greatestFiniteMagnitude
for i in 0 ..< self.lines.count {
let lineFrame = expandedFrameForLine(self.lines[i], boundingWidth: boundsWidth, alignment: self.alignment)
let distance: CGFloat
if point.y < lineFrame.minY {
distance = lineFrame.minY - point.y
} else if point.y > lineFrame.maxY {
distance = point.y - lineFrame.maxY
} else {
distance = 0.0
}
if distance < nearestDistance {
nearestDistance = distance
nearestLineIndex = i
}
}
let line = self.lines[nearestLineIndex]
let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment)
let clampedX = max(lineFrame.minX, min(lineFrame.maxX, point.x))
var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: clampedX - lineFrame.minX, y: 0.0))
if index == self.attributedString.length {
index -= 1
} else if index != 0 {
var glyphStart: CGFloat = 0.0
CTLineGetOffsetForStringIndex(line.line, index, &glyphStart)
if clampedX - lineFrame.minX < glyphStart {
index -= 1
}
}
guard index >= 0, index < self.attributedString.length else {
return nil
}
return (index, self.attributedString.attributes(at: index, effectiveRange: nil))
}
private func attributeRects(name: NSAttributedString.Key, at index: Int) -> [CGRect]? {
var range = NSRange()
let _ = self.attributedString.attribute(name, at: index, effectiveRange: &range)
@ -396,9 +442,9 @@ public final class InstantPageTextItem: InstantPageItem {
guard range.length != 0 else {
return nil
}
let boundsWidth = self.frame.width
var rects: [(CGRect, CGRect)] = []
var startEdge: InstantPageTextRangeRectEdge?
var endEdge: InstantPageTextRangeRectEdge?
@ -419,11 +465,11 @@ public final class InstantPageTextItem: InstantPageItem {
rightOffset = ceil(secondaryOffset)
}
}
let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment)
let width = max(0.0, abs(rightOffset - leftOffset))
if line.range.contains(range.lowerBound) {
let offsetX = floor(CTLineGetOffsetForStringIndex(line.line, range.lowerBound, nil))
startEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height)
@ -437,7 +483,7 @@ public final class InstantPageTextItem: InstantPageItem {
let primaryOffset = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound - 1, &secondaryOffset))
secondaryOffset = floor(secondaryOffset)
let nextOffet = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound, &secondaryOffset))
if primaryOffset != secondaryOffset {
offsetX = secondaryOffset
} else {
@ -446,7 +492,7 @@ public final class InstantPageTextItem: InstantPageItem {
}
endEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height)
}
rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset), y: lineFrame.minY), size: CGSize(width: width, height: lineFrame.size.height))))
}
}
@ -455,7 +501,16 @@ public final class InstantPageTextItem: InstantPageItem {
}
return nil
}
public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? {
guard let result = self.rangeRects(in: range), let start = result.start, let end = result.end, !result.rects.isEmpty else {
return nil
}
let startEdge = TextRangeRectEdge(x: start.x, y: start.y, height: start.height)
let endEdge = TextRangeRectEdge(x: end.x, y: end.y, height: end.height)
return (result.rects, startEdge, endEdge)
}
public func lineRects() -> [CGRect] {
let boundsWidth = self.frame.width
var rects: [CGRect] = []

View file

@ -22,6 +22,7 @@ swift_library(
"//submodules/TelegramUI/Components/ChatControllerInteraction",
"//submodules/TelegramUI/Components/TextLoadingEffect",
"//submodules/TelegramUIPreferences",
"//submodules/TextSelectionNode",
],
visibility = [
"//visibility:public",

View file

@ -12,6 +12,7 @@ import ChatControllerInteraction
import InstantPageUI
import TelegramUIPreferences
import TextLoadingEffect
import TextSelectionNode
public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode {
public final class ContainerNode: ASDisplayNode {
@ -30,7 +31,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
private var linkProgressRects: [CGRect]?
private var linkHighlightingNode: LinkHighlightingNode?
private var linkProgressView: TextLoadingEffectView?
private var textSelectionAdapter: InstantPageMultiTextAdapter?
private var textSelectionNode: TextSelectionNode?
override public var visibility: ListViewItemNodeVisibility {
didSet {
if oldValue != self.visibility {
@ -697,9 +700,82 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
override public func willUpdateIsExtractedToContextPreview(_ value: Bool) {
if !value, let textSelectionNode = self.textSelectionNode {
self.textSelectionNode = nil
self.textSelectionAdapter = nil
textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in
textSelectionNode?.highlightAreaNode.removeFromSupernode()
textSelectionNode?.removeFromSupernode()
})
}
}
override public func updateIsExtractedToContextPreview(_ value: Bool) {
guard value, self.textSelectionNode == nil, let messageItem = self.item, let layout = self.currentPageLayout?.layout, let rootNode = messageItem.controllerInteraction.chatControllerNode() else {
return
}
let items = layout.items.compactMap { $0 as? InstantPageTextItem }.filter { $0.selectable && !$0.attributedString.string.isEmpty }
guard !items.isEmpty else {
return
}
let adapter = InstantPageMultiTextAdapter(items: items)
adapter.frame = self.containerNode.bounds
self.textSelectionAdapter = adapter
self.containerNode.addSubnode(adapter)
let incoming = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId)
let theme = messageItem.presentationData.theme.theme
let selectionColor = incoming ? theme.chat.message.incoming.textSelectionColor : theme.chat.message.outgoing.textSelectionColor
let knobColor = incoming ? theme.chat.message.incoming.textSelectionKnobColor : theme.chat.message.outgoing.textSelectionKnobColor
let textSelectionNode = TextSelectionNode(
theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: theme.overallDarkAppearance),
strings: messageItem.presentationData.strings,
textNodeOrView: .node(adapter),
updateIsActive: { _ in },
present: { [weak self] c, a in
guard let self, let item = self.item else {
return
}
if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info {
item.controllerInteraction.presentControllerInCurrent(c, a)
} else {
item.controllerInteraction.presentGlobalOverlayController(c, a)
}
},
rootView: { [weak rootNode] in
return rootNode?.view
},
performAction: { [weak self] text, action in
guard let self, let item = self.item else {
return
}
item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)
}
)
let enableCopy = (!messageItem.associatedData.isCopyProtectionEnabled && !messageItem.message.isCopyProtected()) || messageItem.message.id.peerId.isVerificationCodes
textSelectionNode.enableCopy = enableCopy
var enableOtherActions = true
if let subject = messageItem.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info {
enableOtherActions = false
}
textSelectionNode.enableQuote = false
textSelectionNode.enableTranslate = enableOtherActions
textSelectionNode.enableShare = enableOtherActions && enableCopy
textSelectionNode.enableLookup = true
textSelectionNode.menuSkipCoordnateConversion = !enableOtherActions
textSelectionNode.frame = self.containerNode.bounds
textSelectionNode.highlightAreaNode.frame = self.containerNode.bounds
self.containerNode.insertSubnode(textSelectionNode.highlightAreaNode, at: 0)
self.containerNode.addSubnode(textSelectionNode)
self.textSelectionNode = textSelectionNode
}
override public func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {