mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Anchor navigation in InstantPage V2 / rich-content bubbles
Tapping an intra-message #fragment link in a rich-data bubble now scrolls the chat so the matching anchor lands just below the content-area top, expanding any enclosing collapsed <details> first. Anchors come from server/AI-sent InstantPages (block .anchor or inline RichText.anchor); the compose path is unchanged. - InstantPageV2View.anchorFrame(name:) resolves an anchor's frame in the live layout (text/codeBlock/thinking/details/table), mirroring findTextItem. - instantPageAnchorPath(in:name:) is a pure model walk returning the <details>-sibling-ordinal path to an anchor; its recursion set matches exactly the containers the V2 layout flattens through layoutBlock (.blockQuote/.cover/.list .blocks), keeping ordinals consistent with the layout's detailsIndexCounter. - InstantPageV2View.firstCollapsedDetails(forOrdinalPath:) maps that path to the first not-yet-expanded details' live index (read, never reproduced). - The rich bubble fills the two stubbed seams: getAnchorRect, and a fragment-link route in tapActionAtPoint that drives a resolve -> expand -> scroll state machine (pendingScrollAnchor + progress guard + a post-relayout hook). Taps are gated off while the message streams. Verified by the full Bazel build; runtime behavior not yet exercised. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c46249178d
commit
a81d0ee944
4 changed files with 359 additions and 6 deletions
|
|
@ -373,3 +373,26 @@ Spec: [`docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.m
|
|||
- **Shimmer runs continuously while the view is displayed** via `ShimmeringMaskView`'s `HierarchyTrackingLayer` self-animation. It does not stop when streaming ends.
|
||||
- **Top-level only; separate stable-id namespace.** Thinking blocks appear only at the top level of the page. They use the `InstantPageV2StableItemId.thinking(Int)` namespace, numbered by a counter independent of content blocks. This means adding or removing a thinking block never renumbers the stable ids of content blocks — which, combined with pageView reuse, ensures content views and reveal state persist as thinking blocks come and go across chunks.
|
||||
- **V1 is a no-op.** `InstantPageLayout.swift` has no `.thinking` case; the block falls through `layoutInstantPageBlock`'s `default:` to an empty layout, so V1 rendering silently skips it.
|
||||
|
||||
## Anchor navigation in rich bubbles (intra-message `#anchor` links)
|
||||
|
||||
Tapping a fragment-only link (`[Jump](#section)`) inside a rich-data bubble scrolls the chat so the matching in-message anchor lands ~8pt below the content-area top, expanding any enclosing collapsed `<details>` first. Anchors come from **server/AI-sent** InstantPages only — block-level `InstantPageBlock.anchor(name)` or inline `RichText.anchor` over a heading/paragraph; the markdown **compose** path deliberately skips generating heading-slug anchors for chat (`markdownBlocksWithGeneratedAnchors` runs only for documents), so user-typed messages have no anchors. The whole downstream scroll chain (`ChatControllerInteraction.scrollToMessageIdWithAnchor` → `ChatMessageBubbleItemNode.getAnchorRect` → `historyNode.scrollToMessage(.bottom(anchorY))`) pre-existed; this feature fills the two bubble-side seams that were stubbed.
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | `InstantPageV2View.anchorFrame(name:)` (live-layout frame walk, mirrors `findTextItem`; handles `.text`/`.codeBlock`/`.thinking`/`.details`/`.table`) + `firstCollapsedDetails(forOrdinalPath:)` (maps an ordinal path to the first not-yet-expanded `<details>`'s live index). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift` | **NEW.** Pure `instantPageAnchorPath(in:name:)` model walk → the `<details>`-sibling-ordinal path to an anchor (`nil` = absent, `[]` = outside any details, `[2,0]` = inside the 3rd top-level details then its 1st nested details) + `richTextContainsAnchor`. |
|
||||
| `…/Chat/ChatMessageRichDataBubbleContentNode/…` | `getAnchorRect` (delegates to `anchorFrame`, +8pt top margin); the `tapActionAtPoint` fragment route + streaming gate; the `scrollToAnchor` resolve→expand→scroll state machine (`pendingScrollAnchor` + progress guard); the post-relayout hook. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **The ordinal path is mapped to live indices, never reproduced.** The layout's `detailsIndexCounter` (`InstantPageV2Layout.swift`) is **expansion-dependent** — a `<details>` nested inside a *collapsed* parent has no index until the parent expands and re-lays-out (a collapsed details has `innerLayout == nil`; its children aren't laid out). So `instantPageAnchorPath` returns ordinals, and `firstCollapsedDetails` reads the real index from the live laid-out `.details` item. Expansion is iterative: expand one collapsed level → `requestMessageUpdate` → the post-relayout hook re-runs `scrollToAnchor` → repeat until the anchor resolves via `anchorFrame`.
|
||||
- **The model walk's recursion set MUST equal the containers the V2 layout recurses through `layoutBlock`** (and thus counts `<details>` in via `detailsIndexCounter`): exactly `.blockQuote`, `.cover`, and `.list`'s `.blocks` items — all of which the layout **flattens** into the parent `items` array (only `layoutDetails` nests a separate `innerLayout`, which is the level boundary). `instantPageAnchorPath` recurses those three sharing the `inout detailsOrdinal`, and treats `.details` as a new level. It deliberately does **NOT** recurse `.postEmbed`/`.collage`/`.slideshow` — the V2 layout lays out only their media/caption (never their child blocks), so it never counts a `<details>` inside them; recursing them would desync the model walk's ordinals from the layout. An anchor inside such a non-laid-out child is unresolvable by `anchorFrame` anyway, so skipping it is a no-op either way.
|
||||
- **`anchorFrame` and the model walk are only ever both consulted when `anchorFrame` fails.** `scrollToAnchor` first tries `anchorFrame` (covers everything currently laid out — top level, expanded details, tables, thinking blocks); only on a miss does it consult `instantPageAnchorPath`. So the only consequential model-walk output is a **non-empty** path (anchor buried in a collapsed details); `nil`/`[]` both no-op.
|
||||
- **`getAnchorRect` stays a pure synchronous query.** ChatController calls it inside `forEachVisibleItemNode`; all expansion is orchestrated by `scrollToAnchor`/`pendingScrollAnchor` **before** the scroll fires. The chat scroll consumes only the returned rect's `minY`.
|
||||
- **Anchor taps are rejected while the message streams** (`TypingDraftMessageAttribute`) → `.none`. So `pendingScrollAnchor` is only ever set post-stream, and the reveal cursor never interacts with anchor scrolling.
|
||||
- **A fragment-only URL (`#…`, empty base) is always intercepted** — never opened as an external URL. If it resolves → scroll; if not (missing or empty anchor) → no-op (press-highlight only). A real URL carrying a fragment (`https://x.com/p#s`, non-empty base) keeps the unchanged external-URL handling.
|
||||
- **The expansion loop terminates** via a progress guard (`lastExpandedPendingDetailsIndex == collapsedIndex` → give up): each relayout pass either resolves+scrolls (clearing pending) or advances to a strictly deeper collapsed `<details>`.
|
||||
- **No `activate:` on the anchor tap action** (unlike external-URL taps): anchor scrolling is local and instant, so the link-loading shimmer (`makeActivate`) would falsely imply network activity. The press-highlight `rects` are still passed.
|
||||
|
|
|
|||
153
submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift
Normal file
153
submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import Foundation
|
||||
import TelegramCore
|
||||
|
||||
/// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the
|
||||
/// details-sibling-ordinal path of enclosing `<details>` blocks (outermost first).
|
||||
///
|
||||
/// - `nil` — the anchor exists nowhere.
|
||||
/// - `[]` — the anchor exists outside any `<details>` (top level, a table cell, a quote, etc.).
|
||||
/// - `[2,0]`— inside the 3rd top-level `<details>`, then that details' 1st nested `<details>`.
|
||||
///
|
||||
/// Ordinals (not layout indices) because the layout's details index counter is
|
||||
/// expansion-dependent. Consumers map ordinals to live indices via
|
||||
/// `InstantPageV2View.firstCollapsedDetails(forOrdinalPath:)`.
|
||||
public func instantPageAnchorPath(in instantPage: InstantPage, name: String) -> [Int]? {
|
||||
var ordinal = 0
|
||||
return instantPageAnchorPathSearch(instantPage.blocks, name: name, detailsOrdinal: &ordinal)
|
||||
}
|
||||
|
||||
/// Searches `blocks` at a single details-nesting level. `detailsOrdinal` is the running count of
|
||||
/// `<details>` blocks already passed at this level; container blocks that flatten in the layout
|
||||
/// (`.blockQuote`/`.list`/`.cover`/`.postEmbed`) recurse sharing this counter, while a `<details>`
|
||||
/// recurses with a fresh counter (a new level).
|
||||
private func instantPageAnchorPathSearch(
|
||||
_ blocks: [InstantPageBlock],
|
||||
name: String,
|
||||
detailsOrdinal: inout Int
|
||||
) -> [Int]? {
|
||||
for block in blocks {
|
||||
switch block {
|
||||
case let .anchor(anchorName):
|
||||
if anchorName == name { return [] }
|
||||
case let .title(text), let .subtitle(text), let .header(text), let .subheader(text),
|
||||
let .paragraph(text), let .footer(text), let .kicker(text), let .thinking(text):
|
||||
if richTextContainsAnchor(text, name: name) { return [] }
|
||||
case let .heading(text, _):
|
||||
if richTextContainsAnchor(text, name: name) { return [] }
|
||||
case let .authorDate(author, _):
|
||||
if richTextContainsAnchor(author, name: name) { return [] }
|
||||
case let .preformatted(text, _):
|
||||
if richTextContainsAnchor(text, name: name) { return [] }
|
||||
case let .pullQuote(text, caption):
|
||||
if richTextContainsAnchor(text, name: name) || richTextContainsAnchor(caption, name: name) { return [] }
|
||||
case let .details(title, childBlocks, _):
|
||||
if richTextContainsAnchor(title, name: name) { return [] } // title is always laid out
|
||||
var childOrdinal = 0
|
||||
if let sub = instantPageAnchorPathSearch(childBlocks, name: name, detailsOrdinal: &childOrdinal) {
|
||||
return [detailsOrdinal] + sub
|
||||
}
|
||||
detailsOrdinal += 1
|
||||
case let .blockQuote(quoteBlocks, caption):
|
||||
if richTextContainsAnchor(caption, name: name) { return [] }
|
||||
if let r = instantPageAnchorPathSearch(quoteBlocks, name: name, detailsOrdinal: &detailsOrdinal) { return r }
|
||||
case let .list(items, _):
|
||||
for listItem in items {
|
||||
switch listItem {
|
||||
case let .text(text, _, _):
|
||||
if richTextContainsAnchor(text, name: name) { return [] }
|
||||
case let .blocks(itemBlocks, _, _):
|
||||
if let r = instantPageAnchorPathSearch(itemBlocks, name: name, detailsOrdinal: &detailsOrdinal) { return r }
|
||||
case .unknown:
|
||||
break
|
||||
}
|
||||
}
|
||||
case let .cover(inner):
|
||||
if let r = instantPageAnchorPathSearch([inner], name: name, detailsOrdinal: &detailsOrdinal) { return r }
|
||||
case let .table(title, rows, _, _):
|
||||
if richTextContainsAnchor(title, name: name) { return [] }
|
||||
for row in rows {
|
||||
for cell in row.cells {
|
||||
if let cellText = cell.text, richTextContainsAnchor(cellText, name: name) { return [] }
|
||||
}
|
||||
}
|
||||
default:
|
||||
// .unsupported/.divider/.formula/.image/.video/.audio/.webEmbed/.channelBanner/.map —
|
||||
// leaf/media blocks with no anchor-bearing text. (.relatedArticles also lands here: the
|
||||
// V2 layout discards its title and lays out only the article media, so its title text is
|
||||
// never rendered.)
|
||||
//
|
||||
// CRITICAL — the recursion set here must match the containers the V2 layout recurses
|
||||
// through layoutBlock (and thus counts <details> in via detailsIndexCounter). Those are
|
||||
// exactly .blockQuote, .cover, and .list's .blocks items — all handled above, sharing
|
||||
// detailsOrdinal. The following carry [InstantPageBlock] children but are deliberately
|
||||
// NOT recursed because the V2 layout does NOT lay their children out as blocks, so it
|
||||
// never counts a nested <details> in them — recursing here while sharing detailsOrdinal
|
||||
// would desync our ordinals from the layout:
|
||||
// • .collage/.slideshow — layoutCollage/layoutSlideshow lay out only .image/.video children.
|
||||
// • .postEmbed — layoutMediaWithCaption lays out only its caption (a real .text item,
|
||||
// so a caption anchor is found by anchorFrame directly); its `blocks` are ignored.
|
||||
// Any anchor inside a non-laid-out child is unresolvable by anchorFrame anyway, so
|
||||
// skipping it here is a no-op either way.
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// True if the `RichText` tree contains an inline `.anchor` whose name equals `name`.
|
||||
private func richTextContainsAnchor(_ text: RichText, name: String) -> Bool {
|
||||
switch text {
|
||||
case .empty, .plain, .image, .formula, .textCustomEmoji:
|
||||
return false
|
||||
case let .anchor(inner, anchorName):
|
||||
if anchorName == name { return true }
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .concat(parts):
|
||||
for part in parts {
|
||||
if richTextContainsAnchor(part, name: name) { return true }
|
||||
}
|
||||
return false
|
||||
case let .bold(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .italic(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .underline(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .strikethrough(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .fixed(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .marked(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .`subscript`(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .superscript(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textAutoEmail(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textAutoPhone(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textAutoUrl(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textBankCard(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textBotCommand(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textCashtag(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textHashtag(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textMention(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textSpoiler(inner):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .url(inner, _, _):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .email(inner, _):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .phone(inner, _):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
case let .textMentionName(inner, _):
|
||||
return richTextContainsAnchor(inner, name: name)
|
||||
}
|
||||
}
|
||||
|
|
@ -2159,6 +2159,43 @@ public extension InstantPageV2View {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The frame (pageView-space) of the anchor `name` in the *currently laid-out* layout.
|
||||
/// Returns nil if the anchor isn't present — e.g. it's inside a collapsed `<details>`
|
||||
/// (whose inner blocks aren't laid out) or doesn't exist. Mirrors `findTextItem`.
|
||||
func anchorFrame(name: String) -> CGRect? {
|
||||
guard let layout = self.currentLayout else { return nil }
|
||||
return findAnchorFrame(in: layout, name: name, accumulatedOffset: .zero)
|
||||
}
|
||||
|
||||
/// Given a details-sibling-ordinal path (from `instantPageAnchorPath`), walk the live layout
|
||||
/// and return the `currentExpandedDetails` index of the FIRST not-yet-expanded `<details>` on
|
||||
/// the path. Returns nil if every details on the path is already expanded, or the path doesn't
|
||||
/// match the live layout. Reads indices from the laid-out items — never reproduces them.
|
||||
func firstCollapsedDetails(forOrdinalPath path: [Int]) -> Int? {
|
||||
guard let layout = self.currentLayout else { return nil }
|
||||
var currentItems = layout.items
|
||||
for ordinal in path {
|
||||
var seen = 0
|
||||
var found: InstantPageV2DetailsItem?
|
||||
for item in currentItems {
|
||||
if case let .details(details) = item {
|
||||
if seen == ordinal {
|
||||
found = details
|
||||
break
|
||||
}
|
||||
seen += 1
|
||||
}
|
||||
}
|
||||
guard let details = found else { return nil }
|
||||
if !details.isExpanded {
|
||||
return details.index
|
||||
}
|
||||
guard let inner = details.innerLayout else { return nil }
|
||||
currentItems = inner.items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private recursion helpers
|
||||
|
|
@ -2221,6 +2258,78 @@ private func findTextItem(
|
|||
return nil
|
||||
}
|
||||
|
||||
private func findAnchorFrame(
|
||||
in layout: InstantPageV2Layout,
|
||||
name: String,
|
||||
accumulatedOffset: CGPoint
|
||||
) -> CGRect? {
|
||||
for item in layout.items {
|
||||
let f = item.frame.offsetBy(dx: accumulatedOffset.x, dy: accumulatedOffset.y)
|
||||
switch item {
|
||||
case let .anchor(anchor):
|
||||
if anchor.name == name {
|
||||
return CGRect(x: f.minX, y: f.minY, width: 0.0, height: 0.0)
|
||||
}
|
||||
case let .text(text):
|
||||
if let (lineIndex, _) = text.textItem.anchors[name], lineIndex < text.textItem.lines.count {
|
||||
let line = text.textItem.lines[lineIndex].frame
|
||||
return CGRect(x: f.minX + line.minX, y: f.minY + line.minY, width: line.width, height: line.height)
|
||||
}
|
||||
case let .codeBlock(block):
|
||||
if let (lineIndex, _) = block.textItem.anchors[name], lineIndex < block.textItem.lines.count {
|
||||
let line = block.textItem.lines[lineIndex].frame
|
||||
return CGRect(
|
||||
x: f.minX + block.textItem.frame.minX + line.minX,
|
||||
y: f.minY + block.textItem.frame.minY + line.minY,
|
||||
width: line.width, height: line.height
|
||||
)
|
||||
}
|
||||
case let .thinking(thinking):
|
||||
if let (lineIndex, _) = thinking.textItem.anchors[name], lineIndex < thinking.textItem.lines.count {
|
||||
let line = thinking.textItem.lines[lineIndex].frame
|
||||
return CGRect(
|
||||
x: f.minX + thinking.textItem.frame.minX + line.minX,
|
||||
y: f.minY + thinking.textItem.frame.minY + line.minY,
|
||||
width: line.width, height: line.height
|
||||
)
|
||||
}
|
||||
case let .details(details):
|
||||
if let (lineIndex, _) = details.titleTextItem.anchors[name], lineIndex < details.titleTextItem.lines.count {
|
||||
let line = details.titleTextItem.lines[lineIndex].frame
|
||||
return CGRect(
|
||||
x: f.minX + details.titleTextItem.frame.minX + line.minX,
|
||||
y: f.minY + details.titleTextItem.frame.minY + line.minY,
|
||||
width: line.width, height: line.height
|
||||
)
|
||||
}
|
||||
if let inner = details.innerLayout {
|
||||
let innerOffset = CGPoint(x: f.minX, y: f.minY + details.titleFrame.maxY)
|
||||
if let hit = findAnchorFrame(in: inner, name: name, accumulatedOffset: innerOffset) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
case let .table(table):
|
||||
for cell in table.cells {
|
||||
if let sub = cell.subLayout {
|
||||
let cellOffset = CGPoint(x: f.minX + table.contentInset + cell.frame.minX, y: f.minY + cell.frame.minY)
|
||||
if let hit = findAnchorFrame(in: sub, name: name, accumulatedOffset: cellOffset) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame {
|
||||
let titleOffset = CGPoint(x: f.minX + table.contentInset + titleFrame.minX, y: f.minY + titleFrame.minY)
|
||||
if let hit = findAnchorFrame(in: titleLayout, name: name, accumulatedOffset: titleOffset) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func collectSelectableTextItems(
|
||||
in layout: InstantPageV2Layout,
|
||||
accumulatedOffset: CGPoint,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
messageStableVersion: UInt32,
|
||||
layout: InstantPageV2Layout)?
|
||||
private var currentExpandedDetails: [Int: Bool] = [:]
|
||||
// Intra-message anchor scroll that is waiting on a collapsed <details> to expand + relayout.
|
||||
private var pendingScrollAnchor: String?
|
||||
// Progress guard: the details index expanded on the previous pending pass.
|
||||
private var lastExpandedPendingDetailsIndex: Int?
|
||||
private var linkProgressDisposable: Disposable?
|
||||
private var linkProgressRects: [CGRect]?
|
||||
private var linkHighlightingNode: LinkHighlightingNode?
|
||||
|
|
@ -675,6 +679,22 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
if self.displayContentsUnderSpoilers {
|
||||
pageView.setDisplayContentsUnderSpoilers(true, atLocation: nil, animated: false)
|
||||
}
|
||||
// Continue an in-flight anchor scroll that is waiting on a <details>
|
||||
// expansion to re-lay-out. This runs on EVERY apply pass (not only the
|
||||
// expand-triggered one), but only does anything while a scroll is pending
|
||||
// — and scrollToAnchor is idempotent: each invocation either resolves and
|
||||
// scrolls (clearing pending) or expands the next collapsed level, and the
|
||||
// progress guard guarantees termination. So an unrelated relayout (theme,
|
||||
// width, reactions) that lands mid-expand simply advances/no-ops the loop.
|
||||
// Deferred via justDispatch to avoid re-entering layout from this apply.
|
||||
if let pendingAnchor = self.pendingScrollAnchor {
|
||||
Queue.mainQueue().justDispatch { [weak self] in
|
||||
guard let self, self.pendingScrollAnchor == pendingAnchor else {
|
||||
return
|
||||
}
|
||||
self.scrollToAnchor(pendingAnchor)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.currentPageLayout = nil
|
||||
self.pageView?.update(
|
||||
|
|
@ -859,6 +879,16 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
}
|
||||
|
||||
let split = self.splitAnchor(urlHit.urlItem.url)
|
||||
if split.base.isEmpty, let anchor = split.anchor {
|
||||
// Don't accept intra-message anchor taps while the message is still streaming.
|
||||
if let item = self.item, item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
|
||||
return ChatMessageBubbleContentTapAction(content: .none)
|
||||
}
|
||||
let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
||||
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
|
||||
self?.scrollToAnchor(anchor)
|
||||
}), rects: rects)
|
||||
}
|
||||
if let webpage = self.currentLoadedWebpage(), webpage.content.url == split.base, let anchor = split.anchor {
|
||||
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
|
||||
self?.scrollToAnchor(anchor)
|
||||
|
|
@ -1187,9 +1217,17 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
}
|
||||
|
||||
override public func getAnchorRect(anchor: String) -> CGRect? {
|
||||
// V2 V0: anchor resolution lives in the V2 view (text-item anchors). Not yet wired through.
|
||||
let _ = anchor
|
||||
return nil
|
||||
guard let pageView = self.pageView, let rect = pageView.anchorFrame(name: anchor) else {
|
||||
return nil
|
||||
}
|
||||
// Small top breathing room so the target isn't flush against the content-area top
|
||||
// (cf. V1 InstantPageControllerNode's -10 offset). The chat scroll consumes only the
|
||||
// returned rect's minY (ChatController's scrollToMessageIdWithAnchor → .bottom(anchorY)),
|
||||
// so pulling minY up by the margin is what lands the anchor below the top edge; the rect
|
||||
// is grown to keep maxY stable should a future caller use the full rect (e.g. a highlight).
|
||||
let topMargin: CGFloat = 8.0
|
||||
let adjusted = CGRect(x: rect.minX, y: max(0.0, rect.minY - topMargin), width: rect.width, height: rect.height + topMargin)
|
||||
return self.view.convert(adjusted, from: pageView)
|
||||
}
|
||||
|
||||
override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? {
|
||||
|
|
@ -1227,10 +1265,40 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
guard let item = self.item else {
|
||||
return
|
||||
}
|
||||
// Empty fragment ("#") is a no-op.
|
||||
if anchor.isEmpty {
|
||||
item.controllerInteraction.scrollToMessageId(item.message.index, 0.0)
|
||||
} else {
|
||||
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
|
||||
self.clearPendingScroll()
|
||||
return
|
||||
}
|
||||
// 1. Anchor is in the currently laid-out content → scroll now.
|
||||
if self.pageView?.anchorFrame(name: anchor) != nil {
|
||||
self.clearPendingScroll()
|
||||
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
|
||||
return
|
||||
}
|
||||
// 2. Not laid out — it may be buried in a collapsed <details>. Find the path and expand
|
||||
// the first collapsed details on it, then retry after the relayout (post-relayout hook).
|
||||
guard let instantPage = item.message.richText?.instantPage,
|
||||
let path = instantPageAnchorPath(in: instantPage, name: anchor),
|
||||
!path.isEmpty,
|
||||
let collapsedIndex = self.pageView?.firstCollapsedDetails(forOrdinalPath: path)
|
||||
else {
|
||||
self.clearPendingScroll()
|
||||
return
|
||||
}
|
||||
// Progress guard: if expanding this same index last pass didn't move us forward, stop.
|
||||
if self.lastExpandedPendingDetailsIndex == collapsedIndex {
|
||||
self.clearPendingScroll()
|
||||
return
|
||||
}
|
||||
self.currentExpandedDetails[collapsedIndex] = true
|
||||
self.pendingScrollAnchor = anchor
|
||||
self.lastExpandedPendingDetailsIndex = collapsedIndex
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
|
||||
private func clearPendingScroll() {
|
||||
self.pendingScrollAnchor = nil
|
||||
self.lastExpandedPendingDetailsIndex = nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue