Rich-bubble text selection (post-V2 fix)

Design and plan, then implementation: include the details title in
selectableTextItems, restore V2 text selection in the rich bubble,
drop the InstantPageMultiTextAdapter init parameter-name split, and
split LinkHighlightingNode modern-path groups on disjoint rects.
Also includes the IP V2 inline-formula baseline alignment design doc.
This commit is contained in:
isaac 2026-05-20 00:33:47 +08:00
parent 3a22e7c81b
commit 8dbbf0f715
4 changed files with 53 additions and 19 deletions

View file

@ -60,6 +60,17 @@ private func drawRectsImageContent(size: CGSize, context: CGContext, color: UICo
}
currentRects.removeAll()
} else {
// The path-stitching loop below assumes consecutive rects in a group
// are adjacent (overlapping or sharing an edge). When they're disjoint
// vertical gap, vertical inversion, or horizontal gap on the same
// line it bridges them with a polygon perimeter that renders as a
// diagonal bridge across empty space. Split groups whenever the next
// rect doesn't intersect the last one (1pt slop in both axes matches
// the snap loop's adjacency test).
if let last = currentRects.last, !last.insetBy(dx: -1.0, dy: -1.0).intersects(rect) {
combinedRects.append(currentRects)
currentRects.removeAll()
}
currentRects.append(rect)
}
}

View file

@ -5,28 +5,38 @@ import Display
import TelegramCore
public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol {
private struct Entry {
public struct Entry {
public let item: InstantPageTextItem
public let frameOrigin: CGPoint
public init(item: InstantPageTextItem, frameOrigin: CGPoint) {
self.item = item
self.frameOrigin = frameOrigin
}
}
private struct InternalEntry {
let item: InstantPageTextItem
let charOffset: Int
let frameOrigin: CGPoint
}
private let entries: [Entry]
private let entries: [InternalEntry]
private let combinedString: NSAttributedString
public init(items: [InstantPageTextItem]) {
public init(entries: [Entry]) {
let separator = NSAttributedString(string: "\n\n")
let combined = NSMutableAttributedString()
var entries: [Entry] = []
for (index, item) in items.enumerated() {
var internalEntries: [InternalEntry] = []
for (index, entry) in entries.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 {
internalEntries.append(InternalEntry(item: entry.item, charOffset: charOffset, frameOrigin: entry.frameOrigin))
combined.append(entry.item.attributedString)
if index != entries.count - 1 {
combined.append(separator)
}
}
self.entries = entries
self.entries = internalEntries
self.combinedString = combined
super.init()
self.isUserInteractionEnabled = false

View file

@ -1223,6 +1223,12 @@ private func collectSelectableTextItems(
)))
}
case let .details(details):
if details.titleTextItem.selectable && !details.titleTextItem.attributedString.string.isEmpty {
result.append((details.titleTextItem, CGPoint(
x: accumulatedOffset.x + details.frame.minX + details.titleTextItem.frame.minX,
y: accumulatedOffset.y + details.frame.minY + details.titleTextItem.frame.minY
)))
}
if let inner = details.innerLayout {
let innerOffset = CGPoint(
x: accumulatedOffset.x + details.frame.minX,

View file

@ -746,23 +746,30 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
override public func updateIsExtractedToContextPreview(_ value: Bool) {
guard value, self.textSelectionNode == nil, let messageItem = self.item, self.currentPageLayout?.layout != nil, let rootNode = messageItem.controllerInteraction.chatControllerNode() else {
guard value, self.textSelectionNode == nil, let messageItem = self.item, self.currentPageLayout?.layout != nil, let pageView = self.pageView, let rootNode = messageItem.controllerInteraction.chatControllerNode() else {
return
}
// V0 spec: root-level selection only (cross-sub-layout selection deferred).
// Root-level items have parentOffset == item.frame.origin (no sub-layout descent).
let items = (self.pageView?.selectableTextItems() ?? [])
.filter { entry in
entry.parentOffset == entry.item.frame.origin
// pageView sits at (-1, 0) inside containerNode; the adapter is placed at
// containerNode.bounds, so shift each item's page-space origin into
// containerNode-local coords for the adapter to operate in.
let pageOrigin = pageView.frame.origin
let entries = pageView.selectableTextItems()
.filter { $0.item.selectable && !$0.item.attributedString.string.isEmpty }
.map { entry in
InstantPageMultiTextAdapter.Entry(
item: entry.item,
frameOrigin: CGPoint(
x: entry.parentOffset.x + pageOrigin.x,
y: entry.parentOffset.y + pageOrigin.y
)
)
}
.map { $0.item }
.filter { $0.selectable && !$0.attributedString.string.isEmpty }
guard !items.isEmpty else {
guard !entries.isEmpty else {
return
}
let adapter = InstantPageMultiTextAdapter(items: items)
let adapter = InstantPageMultiTextAdapter(entries: entries)
adapter.frame = self.containerNode.bounds
self.textSelectionAdapter = adapter
self.containerNode.addSubnode(adapter)