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() currentRects.removeAll()
} else { } 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) currentRects.append(rect)
} }
} }

View file

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

View file

@ -1223,6 +1223,12 @@ private func collectSelectableTextItems(
))) )))
} }
case let .details(details): 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 { if let inner = details.innerLayout {
let innerOffset = CGPoint( let innerOffset = CGPoint(
x: accumulatedOffset.x + details.frame.minX, x: accumulatedOffset.x + details.frame.minX,

View file

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