mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
InstantPage V2: render inline RichText images
Renders `RichText.image(id:, dimensions:)` runs as real fetched images inside `ChatMessageRichDataBubbleContentNode` / `InstantPageV2View`, replacing the gray `InstantPageV2MediaPlaceholderView`. Source is whatever populates `instantPage.media[id]` (server-pushed webpage InstantPages); the markdown converter is unchanged (`markdownImageParsingEnabled` stays false). Architecture mirrors inline custom emoji: inline images are NOT top-level `InstantPageV2LaidOutItem`s. `InstantPageV2View` walks each text view's `line.imageItems`, resolves each MediaId against `layout.media`, and creates `InstantPageV2InlineImageView` children inside a new `imageContainerView` on each `InstantPageV2TextView` (sibling of `renderContainer`, above the reveal mask, below `emojiContainerView`). Streaming reveal participation: image cells contribute their full width to the per-line character rects (mirrors emoji), so the cost map charges the cursor by the image's width when crossing it. When the cursor crosses the cell, `updateImageReveal` pops the view in (opacity 0→1 + scale 0.1→1.0 over 0.2s ease-out), matching `updateEmojiReveal` exactly. `applyReveal` calls both alongside each other. Non-interactive (V1 parity): `isUserInteractionEnabled = false` lets URL-wrapping `RichText.url(text: .image(...))` route taps through to the underlying text view's URL handler. Key files: - New `InstantPageV2InlineImageView.swift`: lightweight `TransformImageNode`-backed view. Picks the fetch signal by EngineMedia kind (.image → chatMessagePhoto; image-mime .file → instantPageImageFile; video .file → chatMessageVideo single frame). `MetaDisposable` cancels fetches on dealloc. - `InstantPageV2Layout.swift`: `media`/`webpage` fields on the layout struct; image cells contribute to char rects; the gray-placeholder branch in `layoutTextItem` is removed; the dead `media`/`webpage` params on `layoutTextItem` are dropped along with their 13 call sites. - `InstantPageRenderer.swift`: `imageContainerView` on text view (sibling of `renderContainer`, between it and `emojiContainerView`); `InlineImageKey` + `InstantPageInlineImageData` types; `updateInlineImages()` + `updateImageReveal()` on `InstantPageV2View`; wired into `update(layout:theme:animation:)`. - `InstantPageV2RevealCost.swift`: `applyReveal` calls `updateImageReveal` next to `updateEmojiReveal` in both the clear-path branch and the entry-walking branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ddf6999908
commit
1d82735d61
4 changed files with 316 additions and 48 deletions
|
|
@ -80,6 +80,27 @@ public final class InstantPageV2RenderContext {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Inline image view data
|
||||
|
||||
private struct InlineImageKey: Hashable {
|
||||
let fileId: Int64
|
||||
let occurrenceIndex: Int
|
||||
}
|
||||
|
||||
/// Per-inline-image state owned by `InstantPageV2View`.
|
||||
/// `textView` is a weak reference back to the parent so `updateImageReveal` can
|
||||
/// look up the current per-text-view character count.
|
||||
private final class InstantPageInlineImageData {
|
||||
let view: InstantPageV2InlineImageView
|
||||
weak var textView: InstantPageV2TextView?
|
||||
var charIndexInItem: Int = 0
|
||||
var revealed: Bool = false
|
||||
|
||||
init(view: InstantPageV2InlineImageView) {
|
||||
self.view = view
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Emoji layer data
|
||||
|
||||
private final class InstantPageEmojiLayerData {
|
||||
|
|
@ -108,6 +129,7 @@ public final class InstantPageV2View: UIView {
|
|||
public let renderContext: InstantPageV2RenderContext?
|
||||
|
||||
private var inlineStickerItemLayers: [InlineStickerItemLayer.Key: InstantPageEmojiLayerData] = [:]
|
||||
private var inlineImageViews: [InlineImageKey: InstantPageInlineImageData] = [:]
|
||||
private var emojiEnableLooping: Bool = true
|
||||
|
||||
/// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping.
|
||||
|
|
@ -234,6 +256,7 @@ public final class InstantPageV2View: UIView {
|
|||
self.itemViewStableIds = newStableIds
|
||||
self.currentLayout = layout
|
||||
self.currentTheme = theme
|
||||
self.updateInlineImages()
|
||||
self.updateInlineEmoji()
|
||||
let enableSpoilerAnimations = self.renderContext.map { $0.context.sharedContext.energyUsageSettings.fullTranslucency } ?? true
|
||||
for view in self.itemViews {
|
||||
|
|
@ -332,6 +355,82 @@ public final class InstantPageV2View: UIView {
|
|||
self.updateEmojiReveal(animated: false)
|
||||
}
|
||||
|
||||
func updateInlineImages() {
|
||||
guard let renderContext = self.renderContext,
|
||||
let layout = self.currentLayout,
|
||||
let theme = self.currentTheme
|
||||
else { return }
|
||||
let context = renderContext.context
|
||||
|
||||
var nextIndexById: [Int64: Int] = [:]
|
||||
var validKeys: Set<InlineImageKey> = []
|
||||
|
||||
for view in self.itemViews {
|
||||
guard let textView = view as? InstantPageV2TextView else { continue }
|
||||
let textItem = textView.item.textItem
|
||||
let boundsWidth = textItem.frame.size.width
|
||||
for line in textItem.lines {
|
||||
if line.imageItems.isEmpty { continue }
|
||||
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
|
||||
for imageItem in line.imageItems {
|
||||
let index = nextIndexById[imageItem.id.id] ?? 0
|
||||
nextIndexById[imageItem.id.id] = index + 1
|
||||
let key = InlineImageKey(fileId: imageItem.id.id, occurrenceIndex: index)
|
||||
guard let media = layout.media[imageItem.id] else { continue }
|
||||
validKeys.insert(key)
|
||||
|
||||
let localX = lineFrame.minX + (imageItem.frame.minX - line.frame.minX)
|
||||
let itemFrame = CGRect(
|
||||
x: localX + v2TextViewClippingInset,
|
||||
y: imageItem.frame.minY + v2TextViewClippingInset,
|
||||
width: imageItem.frame.width,
|
||||
height: imageItem.frame.height
|
||||
)
|
||||
|
||||
let data: InstantPageInlineImageData
|
||||
if let existing = self.inlineImageViews[key] {
|
||||
data = existing
|
||||
if data.view.superview !== textView.imageContainerView {
|
||||
textView.imageContainerView.addSubview(data.view)
|
||||
}
|
||||
} else {
|
||||
let newView = InstantPageV2InlineImageView(
|
||||
media: media,
|
||||
webpage: layout.webpage,
|
||||
frame: itemFrame,
|
||||
context: context,
|
||||
userLocation: .other,
|
||||
theme: theme
|
||||
)
|
||||
// Image starts hidden; updateImageReveal pops it in when the streaming
|
||||
// cursor crosses its char-index. For non-streaming pages (no
|
||||
// `TypingDraftMessageAttribute`), `revealed = true` (via the fallback in
|
||||
// updateImageReveal) sets alpha = 1 unconditionally on the next call.
|
||||
newView.alpha = 0.0
|
||||
data = InstantPageInlineImageData(view: newView)
|
||||
self.inlineImageViews[key] = data
|
||||
textView.imageContainerView.addSubview(newView)
|
||||
}
|
||||
|
||||
data.view.frame = itemFrame
|
||||
data.textView = textView
|
||||
data.charIndexInItem = imageItem.range.location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var removeKeys: [InlineImageKey] = []
|
||||
for (key, data) in self.inlineImageViews where !validKeys.contains(key) {
|
||||
removeKeys.append(key)
|
||||
data.view.removeFromSuperview()
|
||||
}
|
||||
for key in removeKeys {
|
||||
self.inlineImageViews.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
self.updateImageReveal(animated: false)
|
||||
}
|
||||
|
||||
func updateEmojiReveal(animated: Bool) {
|
||||
for (_, data) in self.inlineStickerItemLayers {
|
||||
let revealed: Bool
|
||||
|
|
@ -368,6 +467,41 @@ public final class InstantPageV2View: UIView {
|
|||
self.updateEmojiVisibility()
|
||||
}
|
||||
|
||||
func updateImageReveal(animated: Bool) {
|
||||
for (_, data) in self.inlineImageViews {
|
||||
let revealed: Bool
|
||||
if let textView = data.textView, let count = textView.currentRevealCharacterCount {
|
||||
revealed = data.charIndexInItem < count
|
||||
} else {
|
||||
revealed = true
|
||||
}
|
||||
if data.revealed == revealed {
|
||||
continue
|
||||
}
|
||||
data.revealed = revealed
|
||||
if revealed {
|
||||
if animated {
|
||||
data.view.layer.opacity = 1.0
|
||||
let opacityAnim = CABasicAnimation(keyPath: "opacity")
|
||||
opacityAnim.fromValue = 0.0
|
||||
opacityAnim.toValue = 1.0
|
||||
opacityAnim.duration = 0.2
|
||||
data.view.layer.add(opacityAnim, forKey: "inlineImageRevealOpacity")
|
||||
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
|
||||
scaleAnim.fromValue = 0.1
|
||||
scaleAnim.toValue = 1.0
|
||||
scaleAnim.duration = 0.2
|
||||
scaleAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
data.view.layer.add(scaleAnim, forKey: "inlineImageRevealScale")
|
||||
} else {
|
||||
data.view.layer.opacity = 1.0
|
||||
}
|
||||
} else {
|
||||
data.view.layer.opacity = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateEmojiVisibility() {
|
||||
for (_, data) in self.inlineStickerItemLayers {
|
||||
let onScreen: Bool
|
||||
|
|
@ -689,6 +823,11 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|||
|
||||
private let renderContainer: UIView
|
||||
private let renderView: TextRenderView
|
||||
/// Sibling of `renderContainer`, holds inline `InstantPageV2InlineImageView`s
|
||||
/// owned by the parent `InstantPageV2View`. Sits BELOW `emojiContainerView`
|
||||
/// (so a colocated emoji renders above an image) and ABOVE `renderContainer`
|
||||
/// (so the reveal mask wipes text without clipping images).
|
||||
let imageContainerView: UIView = UIView()
|
||||
let emojiContainerView: UIView = UIView()
|
||||
let spoilerContainerView: UIView = UIView()
|
||||
private var dustNode: InvisibleInkDustNode?
|
||||
|
|
@ -726,6 +865,10 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|||
self.renderView.frame = self.bounds
|
||||
self.renderContainer.addSubview(self.renderView)
|
||||
|
||||
self.imageContainerView.frame = self.bounds
|
||||
self.imageContainerView.isUserInteractionEnabled = false
|
||||
self.addSubview(self.imageContainerView)
|
||||
|
||||
self.emojiContainerView.frame = self.bounds
|
||||
self.emojiContainerView.isUserInteractionEnabled = false
|
||||
self.addSubview(self.emojiContainerView)
|
||||
|
|
@ -745,6 +888,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|||
self.item = item
|
||||
self.renderView.item = item
|
||||
self.renderView.setNeedsDisplay()
|
||||
self.imageContainerView.frame = self.bounds
|
||||
self.emojiContainerView.frame = self.bounds
|
||||
self.spoilerContainerView.frame = self.bounds
|
||||
self.renderView.displayContentsUnderSpoilers = self.displayContentsUnderSpoilers
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import TelegramCore
|
||||
import SwiftSignalKit
|
||||
import TelegramPresentationData
|
||||
import AccountContext
|
||||
import PhotoResources
|
||||
import MediaResources
|
||||
|
||||
/// Lightweight inline image view for InstantPage V2 — wraps a `TransformImageNode`
|
||||
/// to render a single `RichText.image` cell inside a text view.
|
||||
///
|
||||
/// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in
|
||||
/// the view-factory switch). Hosted inside the parent text view's
|
||||
/// `imageContainerView` (sibling of `renderContainer`, above the reveal mask,
|
||||
/// below `emojiContainerView`), so the streaming reveal can wipe text glyphs
|
||||
/// while the image pops in independently. Non-interactive — taps pass through
|
||||
/// to the text view, so a URL-wrapping `RichText.url(text: .image(...))`
|
||||
/// continues to route taps to the URL handler.
|
||||
final class InstantPageV2InlineImageView: UIView {
|
||||
let fileId: Int64
|
||||
private let imageNode: TransformImageNode
|
||||
private let media: EngineMedia
|
||||
private let theme: InstantPageTheme
|
||||
private let fetchedDisposable = MetaDisposable()
|
||||
|
||||
init(media: EngineMedia,
|
||||
webpage: TelegramMediaWebpage?,
|
||||
frame: CGRect,
|
||||
context: AccountContext,
|
||||
userLocation: MediaResourceUserLocation,
|
||||
theme: InstantPageTheme) {
|
||||
self.media = media
|
||||
self.theme = theme
|
||||
self.fileId = media.id?.id ?? 0
|
||||
self.imageNode = TransformImageNode()
|
||||
|
||||
super.init(frame: frame)
|
||||
self.isUserInteractionEnabled = false
|
||||
self.addSubview(self.imageNode.view)
|
||||
|
||||
self.bindSignal(webpage: webpage, context: context, userLocation: userLocation)
|
||||
self.applyLayout(size: frame.size)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
deinit {
|
||||
self.fetchedDisposable.dispose()
|
||||
}
|
||||
|
||||
private func bindSignal(webpage: TelegramMediaWebpage?,
|
||||
context: AccountContext,
|
||||
userLocation: MediaResourceUserLocation) {
|
||||
// Without a webpage we can't form a `WebpageReference` for the standard
|
||||
// chat-message signals, so the image node stays at its empty colour.
|
||||
guard let webpage = webpage else { return }
|
||||
let webPageRef = WebpageReference(webpage)
|
||||
|
||||
switch self.media {
|
||||
case let .image(image):
|
||||
let imageReference = ImageMediaReference.webPage(webPage: webPageRef, media: image)
|
||||
self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox,
|
||||
userLocation: userLocation,
|
||||
photoReference: imageReference))
|
||||
// Non-interactive: always auto-fetch so the image arrives without a tap.
|
||||
self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context,
|
||||
userLocation: userLocation,
|
||||
photoReference: imageReference,
|
||||
displayAtSize: nil,
|
||||
storeToDownloadsPeerId: nil).start())
|
||||
case let .file(file):
|
||||
let fileReference = FileMediaReference.webPage(webPage: webPageRef, media: file)
|
||||
if file.mimeType.hasPrefix("image/") {
|
||||
self.fetchedDisposable.set(freeMediaFileInteractiveFetched(account: context.account,
|
||||
userLocation: userLocation,
|
||||
fileReference: fileReference).start())
|
||||
self.imageNode.setSignal(instantPageImageFile(account: context.account,
|
||||
userLocation: userLocation,
|
||||
fileReference: fileReference,
|
||||
fetched: true))
|
||||
} else {
|
||||
// Video / animated file: render a single still frame. No play overlay.
|
||||
self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox,
|
||||
userLocation: userLocation,
|
||||
videoReference: fileReference))
|
||||
}
|
||||
default:
|
||||
// RichText.image's MediaId resolves to .image or .file in practice; other
|
||||
// EngineMedia kinds (geo, webpage, story, ...) leave the image node blank.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func applyLayout(size: CGSize) {
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
self.imageNode.frame = CGRect(origin: .zero, size: size)
|
||||
|
||||
let intrinsic: CGSize
|
||||
switch self.media {
|
||||
case let .image(image):
|
||||
if let largest = largestImageRepresentation(image.representations) {
|
||||
intrinsic = largest.dimensions.cgSize
|
||||
} else {
|
||||
intrinsic = size
|
||||
}
|
||||
case let .file(file):
|
||||
intrinsic = file.dimensions?.cgSize ?? size
|
||||
default:
|
||||
intrinsic = size
|
||||
}
|
||||
|
||||
let imageSize = intrinsic.aspectFilled(size)
|
||||
let arguments = TransformImageArguments(
|
||||
corners: ImageCorners(),
|
||||
imageSize: imageSize,
|
||||
boundingSize: size,
|
||||
intrinsicInsets: UIEdgeInsets(),
|
||||
emptyColor: nil
|
||||
)
|
||||
let apply = self.imageNode.asyncLayout()(arguments)
|
||||
apply()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if self.imageNode.frame.size != self.bounds.size {
|
||||
self.applyLayout(size: self.bounds.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,11 +14,23 @@ public struct InstantPageV2Layout {
|
|||
public let items: [InstantPageV2LaidOutItem]
|
||||
/// Snapshot of the `index` values of every `.details` item present in `items`, captured at layout time.
|
||||
public let detailsIndices: [Int]
|
||||
/// Media dictionary inherited from the page's `LayoutContext.media`. Used by
|
||||
/// `InstantPageV2View.updateInlineImages()` to resolve each text view's
|
||||
/// `line.imageItems` MediaIds at update time. Nested layouts (details body,
|
||||
/// table cells, table title) carry the parent's same map.
|
||||
public let media: [EngineMedia.Id: EngineMedia]
|
||||
/// Webpage carried for the same reason — `updateInlineImages()` needs it to
|
||||
/// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May
|
||||
/// be nil for non-webpage-anchored layouts; in that case the lookup proceeds
|
||||
/// but no fetch signal can be bound (image view simply isn't created).
|
||||
public let webpage: TelegramMediaWebpage?
|
||||
|
||||
public init(contentSize: CGSize, items: [InstantPageV2LaidOutItem], detailsIndices: [Int]) {
|
||||
public init(contentSize: CGSize, items: [InstantPageV2LaidOutItem], detailsIndices: [Int], media: [EngineMedia.Id: EngineMedia] = [:], webpage: TelegramMediaWebpage? = nil) {
|
||||
self.contentSize = contentSize
|
||||
self.items = items
|
||||
self.detailsIndices = detailsIndices
|
||||
self.media = media
|
||||
self.webpage = webpage
|
||||
}
|
||||
|
||||
/// Returns every `InstantPageMedia` produced by this layout (or its nested sub-layouts)
|
||||
|
|
@ -516,7 +528,7 @@ private func layoutBlockSequence(
|
|||
contentSize.width = min(maxX, boundingWidth)
|
||||
}
|
||||
|
||||
return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices)
|
||||
return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices, media: context.media, webpage: context.webpage)
|
||||
}
|
||||
|
||||
private func layoutBlock(
|
||||
|
|
@ -920,8 +932,6 @@ private func layoutDetails(
|
|||
attributedStringForRichText(title, styleStack: titleStyleStack),
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0 - 32.0, // reserve right edge for chevron
|
||||
offset: CGPoint(x: horizontalInset, y: 12.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1023,8 +1033,6 @@ private func layoutTable(
|
|||
attrStr,
|
||||
boundingWidth: cellWidthLimit - totalCellPadding,
|
||||
offset: CGPoint(),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
minimizeWidth: true,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
|
|
@ -1035,8 +1043,6 @@ private func layoutTable(
|
|||
attrStr,
|
||||
boundingWidth: cellWidthLimit - totalCellPadding,
|
||||
offset: CGPoint(),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
).0 {
|
||||
|
|
@ -1304,7 +1310,7 @@ private func layoutTable(
|
|||
// Translate all items in the sub-layout by the inset.
|
||||
let delta = CGPoint(x: horizOffset, y: vertOffset)
|
||||
let translatedItems = sl.items.map { $0.offsetBy(delta) }
|
||||
sl = InstantPageV2Layout(contentSize: sl.contentSize, items: translatedItems, detailsIndices: sl.detailsIndices)
|
||||
sl = InstantPageV2Layout(contentSize: sl.contentSize, items: translatedItems, detailsIndices: sl.detailsIndices, media: sl.media, webpage: sl.webpage)
|
||||
subLayout = sl
|
||||
}
|
||||
|
||||
|
|
@ -1508,8 +1514,6 @@ private func layoutCaptionAndCredit(
|
|||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: y),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1537,8 +1541,6 @@ private func layoutCaptionAndCredit(
|
|||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
alignment: rtl ? .right : .natural,
|
||||
offset: CGPoint(x: horizontalInset, y: y),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1679,8 +1681,6 @@ private func layoutSimpleText(
|
|||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1701,8 +1701,6 @@ private func layoutHeading(
|
|||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1726,8 +1724,6 @@ private func layoutParagraph(
|
|||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1798,8 +1794,6 @@ private func layoutAuthorDate(
|
|||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
alignment: alignment,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1862,8 +1856,6 @@ private func layoutCodeBlock(
|
|||
attributedString,
|
||||
boundingWidth: innerWidth,
|
||||
offset: CGPoint(x: 0.0, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
opaqueBackground: true,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
|
|
@ -1946,8 +1938,6 @@ private func layoutBlockQuote(
|
|||
boundingWidth: textBoundingWidth,
|
||||
alignment: textAlignment,
|
||||
offset: CGPoint(x: textX, y: contentHeight),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1969,8 +1959,6 @@ private func layoutBlockQuote(
|
|||
boundingWidth: textBoundingWidth,
|
||||
alignment: textAlignment,
|
||||
offset: CGPoint(x: textX, y: contentHeight),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -2126,8 +2114,6 @@ private func layoutList(
|
|||
attrStr,
|
||||
boundingWidth: textWidth,
|
||||
offset: CGPoint(x: textX, y: contentHeight),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -2411,8 +2397,10 @@ private func attributedStringForPreformattedText(_ text: RichText, language: Str
|
|||
// MARK: - V2 text-item layout (ported from V1 InstantPageTextItem.swift layoutTextItemWithString)
|
||||
//
|
||||
// V0 difference from V1:
|
||||
// * Inline image runs produce `.mediaPlaceholder(kind: .image, frame:, cornerRadius: 0)` items
|
||||
// instead of `InstantPageImageItem`.
|
||||
// * Inline image runs are NOT emitted as items here. They are discovered at view-update time
|
||||
// by `InstantPageV2View.updateInlineImages()`, which walks each text view's `line.imageItems`
|
||||
// and creates `InstantPageV2InlineImageView`s attached to the text view's `imageContainerView`
|
||||
// (the pop-in animation mirrors the inline custom-emoji ownership model).
|
||||
// * Inline formula runs produce `.formula(InstantPageV2FormulaItem(...))` items carrying the
|
||||
// rendered math image (see `InstantPageV2FormulaView`); the line's `formulaItems` field
|
||||
// already provides the attachment + frame.
|
||||
|
|
@ -2485,8 +2473,6 @@ func layoutTextItem(
|
|||
horizontalInset: CGFloat = 0.0,
|
||||
alignment: NSTextAlignment = .natural,
|
||||
offset: CGPoint,
|
||||
media: [EngineMedia.Id: EngineMedia] = [:],
|
||||
webpage: TelegramMediaWebpage? = nil,
|
||||
minimizeWidth: Bool = false,
|
||||
fitToWidth: Bool = false,
|
||||
maxNumberOfLines: Int = 0,
|
||||
|
|
@ -2814,6 +2800,17 @@ func layoutTextItem(
|
|||
rects[localIndex] = CGRect(x: x, y: 0.0, width: emoji.size, height: emoji.size)
|
||||
}
|
||||
}
|
||||
for image in pendingImages {
|
||||
let localIndex = image.range.location - lineRange.location
|
||||
if localIndex >= 0 && localIndex < rects.count {
|
||||
let x = CTLineGetOffsetForStringIndex(line, image.range.location, nil)
|
||||
// Image cell sits bottom-on-baseline (frame loop: y = baselineY - image.size.height).
|
||||
// Baseline-relative cell: y = 0, height = image.size.height. The full width feeds
|
||||
// the reveal cost map so the streaming cursor is charged the image's width when
|
||||
// crossing it — same as an emoji cell.
|
||||
rects[localIndex] = CGRect(x: x, y: 0.0, width: image.size.width, height: image.size.height)
|
||||
}
|
||||
}
|
||||
lineCharacterRects = rects
|
||||
} else {
|
||||
lineCharacterRects = nil
|
||||
|
|
@ -2870,21 +2867,12 @@ func layoutTextItem(
|
|||
let effectiveOffset = offset
|
||||
for line in textItem.lines {
|
||||
let lineFrame = v2FrameForLine(line, boundingWidth: boundingWidth, alignment: alignment)
|
||||
if let _ = webpage {
|
||||
for imageItem in line.imageItems {
|
||||
if media[imageItem.id] != nil {
|
||||
let placeholderFrame = imageItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y)
|
||||
let placeholder = InstantPageV2MediaPlaceholderItem(
|
||||
frame: placeholderFrame,
|
||||
kind: .image,
|
||||
cornerRadius: 0.0
|
||||
)
|
||||
additionalItems.append(.mediaPlaceholder(placeholder))
|
||||
if placeholderFrame.minY < topInset { topInset = placeholderFrame.minY }
|
||||
if placeholderFrame.maxY > height { bottomInset = max(bottomInset, placeholderFrame.maxY - height) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inline images (RichText.image) are NOT emitted as top-level items here. They are
|
||||
// discovered at view-update time by InstantPageV2View.updateInlineImages(), which
|
||||
// walks each text view's `line.imageItems` and creates an InstantPageV2InlineImageView
|
||||
// attached to the text view's imageContainerView. The pop-in animation reuses the
|
||||
// emoji-style ownership model. See the inline-image design doc:
|
||||
// docs/superpowers/specs/2026-05-28-instantpage-v2-inline-image-design.md.
|
||||
for formulaItem in line.formulaItems {
|
||||
let formulaFrame = formulaItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y)
|
||||
let item = InstantPageV2FormulaItem(
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ public extension InstantPageV2View {
|
|||
clearRevealOn(view: view, animated: animated)
|
||||
}
|
||||
self.updateEmojiReveal(animated: animated)
|
||||
self.updateImageReveal(animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -370,6 +371,7 @@ public extension InstantPageV2View {
|
|||
}
|
||||
}
|
||||
self.updateEmojiReveal(animated: animated)
|
||||
self.updateImageReveal(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue