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:
isaac 2026-05-28 14:21:25 +02:00
parent ddf6999908
commit 1d82735d61
4 changed files with 316 additions and 48 deletions

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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(

View file

@ -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)
}
}