Telegram-iOS/submodules/InstantPageUI/Sources/InstantPageRenderer.swift
isaac 1d82735d61 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>
2026-05-28 14:21:25 +02:00

2235 lines
98 KiB
Swift

import Foundation
import UIKit
import Display
import CheckNode
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import GalleryUI
import ComponentFlow
import TextFormat
import EmojiTextAttachmentView
import AnimationCache
import MultiAnimationRenderer
import InvisibleInkDustNode
// MARK: - Stable item identity (for view reuse on re-layouts)
/// Stable identity for an `InstantPageV2LaidOutItem` across `update()` calls. The renderer
/// uses this to harvest existing item views and reuse them when the new layout still has
/// an item with the same id preventing the media wrappers from torching their fetch
/// signals + image content on every chat-bubble re-apply.
///
/// Media items use their `media.index` (already unique within a page and used as the
/// gallery registry key). Details items use their `details.index`. Other items have no
/// intrinsic identity, so the renderer assigns them a `(case-tag, positional-index-in-items)`
/// pair.
public enum InstantPageV2StableItemId: Hashable {
case media(Int) // media.index (4 media cases share this namespace)
case details(Int) // details.index
case positional(InstantPageV2ItemKind, Int) // (caseTag, items-array position)
}
public enum InstantPageV2ItemKind: Hashable {
case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula
}
// MARK: - Render context
/// Bundle of render-time dependencies required to display real media inside an InstantPage V2
/// view. Tied to an `InstantPageV2View` for the view's lifetime if any field would change
/// (typically because the bubble was recycled with a different webpage), the caller must
/// rebuild the V2View with a fresh render context.
///
/// `renderContext == nil` is permitted: the V2View falls back to grey-placeholder rendering
/// for the four media kinds (image/video/map/coverImage). This keeps the existing zero-arg
/// `InstantPageV2View()` constructor usable.
public final class InstantPageV2RenderContext {
public let context: AccountContext
public let webpage: TelegramMediaWebpage
public let sourceLocation: InstantPageSourceLocation
public let imageReference: (TelegramMediaImage) -> ImageMediaReference
public let fileReference: (TelegramMediaFile) -> FileMediaReference
public let present: (ViewController, Any?) -> Void
public let push: (ViewController) -> Void
public let openUrl: (InstantPageUrlItem) -> Void
public let baseNavigationController: () -> NavigationController?
public init(
context: AccountContext,
webpage: TelegramMediaWebpage,
sourceLocation: InstantPageSourceLocation,
imageReference: @escaping (TelegramMediaImage) -> ImageMediaReference,
fileReference: @escaping (TelegramMediaFile) -> FileMediaReference,
present: @escaping (ViewController, Any?) -> Void,
push: @escaping (ViewController) -> Void,
openUrl: @escaping (InstantPageUrlItem) -> Void,
baseNavigationController: @escaping () -> NavigationController?
) {
self.context = context
self.webpage = webpage
self.sourceLocation = sourceLocation
self.imageReference = imageReference
self.fileReference = fileReference
self.present = present
self.push = push
self.openUrl = openUrl
self.baseNavigationController = baseNavigationController
}
}
// 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 {
let itemLayer: InlineStickerItemLayer
weak var textView: InstantPageV2TextView?
var charIndexInItem: Int = 0
var revealed: Bool = false
init(itemLayer: InlineStickerItemLayer) {
self.itemLayer = itemLayer
}
}
// MARK: - Public renderer
public final class InstantPageV2View: UIView {
public private(set) var currentLayout: InstantPageV2Layout?
public private(set) var currentTheme: InstantPageTheme?
/// Invoked when a details title is tapped. Bubble routes to its expand-state mutation + requestUpdate.
public var detailsTapped: ((_ index: Int) -> Void)?
var itemViews: [InstantPageItemView] = []
private var itemViewStableIds: [InstantPageV2StableItemId] = []
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.
/// `nil` means "not visible" emoji don't animate. The root rect is set by the bubble and
/// propagated down the nested tree (details/table) by `propagateVisibilityRect`.
public var visibilityRect: CGRect? {
didSet {
if oldValue != self.visibilityRect {
self.updateEmojiVisibility()
}
}
}
public private(set) var displayContentsUnderSpoilers: Bool = false
// Weak references to every media wrapper in the tree, keyed by `InstantPageMedia.index`.
// Used by `transitionArgsFor` and `applyHiddenMedia` so the gallery transition + hidden-source
// state can find a wrapper without walking the view hierarchy. Nested V2Views (details body,
// table cells) forward their registrations to the root via `rootMediaRegistryHost`.
var mediaRegistry: [Int: Weak<UIView>] = [:]
// Pointer to the root V2View's registry host. The root sets this to `self`; nested views
// inherit it via `propagateRegistryHost(to:)` in `update(layout:theme:animation:)`.
weak var rootMediaRegistryHost: InstantPageV2View?
var effectiveRegistryHost: InstantPageV2View {
return self.rootMediaRegistryHost ?? self
}
/// Walks the `rootMediaRegistryHost` chain transitively until it finds a self-referencing
/// host (the true root). Necessary because nested details blocks can leave an inner body's
/// `rootMediaRegistryHost` pointing at an intermediate body rather than the outer root
/// `propagateRegistryHost(to:)` only walks one hop, so the chain must be followed at lookup.
var trueRegistryRoot: InstantPageV2View {
var host: InstantPageV2View = self
while let next = host.rootMediaRegistryHost, next !== host {
host = next
}
return host
}
public init(renderContext: InstantPageV2RenderContext?) {
self.renderContext = renderContext
super.init(frame: .zero)
self.backgroundColor = .clear
self.isOpaque = false
self.rootMediaRegistryHost = self
}
public convenience init() {
self.init(renderContext: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Rebuilds the child view hierarchy from `layout`. The caller is responsible for
/// sizing `self.frame` to `layout.contentSize`; this method does not touch its own frame.
///
/// Reuse pass: existing item views are harvested into a `[stableId: view]` map keyed
/// off `itemViewStableIds`. For each new item we look up its stable id; on a hit (and
/// matching concrete view class) the existing view is reused via its typed
/// `update(item:theme:[renderContext:])`, on a miss we fall back to `makeItemView`. Un-reused
/// views are removed from the superview at the end. This preserves the four media wrappers
/// (and any nested V2Views inside details/table) across chat-bubble re-applies, which would
/// otherwise torch in-flight image fetches on every list update.
public func update(
layout: InstantPageV2Layout,
theme: InstantPageTheme,
animation: ListViewItemUpdateAnimation
) {
// Build map of existing views by stable id.
var oldViewsById: [InstantPageV2StableItemId: InstantPageItemView] = [:]
for (oldIndex, oldId) in self.itemViewStableIds.enumerated() {
oldViewsById[oldId] = self.itemViews[oldIndex]
}
var newItemViews: [InstantPageItemView] = []
var newStableIds: [InstantPageV2StableItemId] = []
var reusedIds: Set<InstantPageV2StableItemId> = []
for (position, item) in layout.items.enumerated() {
let id = InstantPageV2View.stableId(for: item, atPosition: position)
if let existing = oldViewsById[id], let reusedView = self.reuse(existingView: existing, for: item, theme: theme, animation: animation) {
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
if animation.isAnimated && reusedView.frame != newFrame {
// A collapsing details view keeps its body alive; remove it once this
// frame-shrink (the clip that hides it) finishes see finalizePendingCollapse().
let detailsView = reusedView as? InstantPageV2DetailsView
animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in
detailsView?.finalizePendingCollapse()
})
} else {
reusedView.frame = newFrame
(reusedView as? InstantPageV2DetailsView)?.finalizePendingCollapse()
}
newItemViews.append(reusedView)
newStableIds.append(id)
reusedIds.insert(id)
// Already in subviews from the previous update; just keep it.
} else {
guard let newView = self.makeItemView(for: item, theme: theme) else { continue }
newItemViews.append(newView)
newStableIds.append(id)
self.addSubview(newView)
self.propagateRegistryHost(to: newView)
}
}
// Remove views that weren't reused.
for (id, view) in oldViewsById where !reusedIds.contains(id) {
view.removeFromSuperview()
}
// Z-order: bring the reused views to the front in declaration order so the
// sublayer/subview stack matches `layout.items` order.
for view in newItemViews {
self.bringSubviewToFront(view)
}
self.itemViews = newItemViews
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 {
if let textView = view as? InstantPageV2TextView {
textView.enableSpoilerAnimations = enableSpoilerAnimations
// makeItemView builds fresh text views via init only (no update(item:theme:)), so
// build their dust here; updateSpoiler is idempotent (no-op when there are no spoilers).
textView.updateSpoiler(animated: false)
}
}
// Force the current reveal state (true OR false) onto every text view every layout, so a
// positionally-reused text view cannot retain a stale reveal flag from prior content.
self.setDisplayContentsUnderSpoilers(self.displayContentsUnderSpoilers, atLocation: nil, animated: false)
}
func updateInlineEmoji() {
guard let rc = self.renderContext else { return }
let context = rc.context
let cache = context.animationCache
let renderer = context.animationRenderer
self.emojiEnableLooping = context.sharedContext.energyUsageSettings.loopEmoji
var nextIndexById: [Int64: Int] = [:]
var validIds: [InlineStickerItemLayer.Key] = []
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.emojiItems.isEmpty { continue }
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
for emojiItem in line.emojiItems {
let index = nextIndexById[emojiItem.emoji.fileId] ?? 0
nextIndexById[emojiItem.emoji.fileId] = index + 1
let id = InlineStickerItemLayer.Key(id: emojiItem.emoji.fileId, index: index)
validIds.append(id)
let itemSize = emojiItem.frame.width
let localX = lineFrame.minX + (emojiItem.frame.minX - line.frame.minX)
let itemFrame = CGRect(
x: localX + v2TextViewClippingInset,
y: emojiItem.frame.minY + v2TextViewClippingInset,
width: itemSize,
height: itemSize
)
var textColor: UIColor?
if emojiItem.range.location < textItem.attributedString.length {
textColor = textItem.attributedString.attribute(.foregroundColor, at: emojiItem.range.location, effectiveRange: nil) as? UIColor
}
let data: InstantPageEmojiLayerData
if let existing = self.inlineStickerItemLayers[id] {
data = existing
if data.itemLayer.superlayer !== textView.emojiContainerView.layer {
textView.emojiContainerView.layer.addSublayer(data.itemLayer)
}
} else {
let pointSize = floor(itemSize * 1.3)
let layer = InlineStickerItemLayer(
context: context,
userLocation: .other,
attemptSynchronousLoad: false,
emoji: emojiItem.emoji,
file: emojiItem.emoji.file,
cache: cache,
renderer: renderer,
placeholderColor: UIColor(white: 0.5, alpha: 0.3),
pointSize: CGSize(width: pointSize, height: pointSize),
dynamicColor: textColor
)
layer.opacity = 0.0
data = InstantPageEmojiLayerData(itemLayer: layer)
self.inlineStickerItemLayers[id] = data
textView.emojiContainerView.layer.addSublayer(layer)
}
data.itemLayer.dynamicColor = textColor
data.itemLayer.frame = itemFrame
data.textView = textView
data.charIndexInItem = emojiItem.range.location
}
}
}
var removeKeys: [InlineStickerItemLayer.Key] = []
for (key, data) in self.inlineStickerItemLayers where !validIds.contains(key) {
removeKeys.append(key)
data.itemLayer.removeFromSuperlayer()
}
for key in removeKeys {
self.inlineStickerItemLayers.removeValue(forKey: key)
}
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
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.itemLayer.opacity = 1.0
let opacityAnim = CABasicAnimation(keyPath: "opacity")
opacityAnim.fromValue = 0.0
opacityAnim.toValue = 1.0
opacityAnim.duration = 0.2
data.itemLayer.add(opacityAnim, forKey: "emojiRevealOpacity")
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
scaleAnim.fromValue = 0.1
scaleAnim.toValue = 1.0
scaleAnim.duration = 0.2
scaleAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
data.itemLayer.add(scaleAnim, forKey: "emojiRevealScale")
} else {
data.itemLayer.opacity = 1.0
}
} else {
data.itemLayer.opacity = 0.0
}
}
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
if let visibilityRect = self.visibilityRect, let textView = data.textView {
let rectInSelf = textView.convert(data.itemLayer.frame, to: self)
onScreen = rectInSelf.intersects(visibilityRect)
} else {
// No visibility rect == not tracked / off-screen don't animate. The root view's
// rect is propagated down the nested tree (details bodies, table cells/title) by
// `propagateVisibilityRect`, so a nil here genuinely means "not visible".
onScreen = false
}
data.itemLayer.isVisibleForAnimations = self.emojiEnableLooping && data.revealed && onScreen
}
self.propagateVisibilityRect()
}
// Pushes this view's `visibilityRect` down into every nested V2 view (details body, table
// title + cells), converted into each child's coordinate space. Each child's `visibilityRect`
// didSet re-runs `updateEmojiVisibility`, which propagates one level further so a single
// root assignment fans out across the whole tree.
private func propagateVisibilityRect() {
for view in self.itemViews {
for nested in InstantPageV2View.nestedV2Views(of: view) {
let childRect = self.visibilityRect.map { self.convert($0, to: nested) }
if nested.visibilityRect != childRect {
nested.visibilityRect = childRect
}
}
}
}
/// Reveals (or re-hides) every spoiler in this view and nested details/table V2 views.
/// `location` (this view's coords) drives the dust explosion origin for whichever text view
/// contains it; the rest just toggle. Single-flag behavior mirrors ChatMessageTextBubbleContentNode.
public func setDisplayContentsUnderSpoilers(_ value: Bool, atLocation location: CGPoint?, animated: Bool) {
self.displayContentsUnderSpoilers = value
for view in self.itemViews {
if let textView = view as? InstantPageV2TextView {
let childLocation = location.map { self.convert($0, to: textView) }
textView.setDisplayContentsUnderSpoilers(value, atLocation: childLocation, animated: animated)
}
for nested in InstantPageV2View.nestedV2Views(of: view) {
let childLocation = location.map { self.convert($0, to: nested) }
nested.setDisplayContentsUnderSpoilers(value, atLocation: childLocation, animated: animated)
}
}
}
private static func nestedV2Views(of view: InstantPageItemView) -> [InstantPageV2View] {
if let detailsView = view as? InstantPageV2DetailsView {
return detailsView.bodyView.map { [$0] } ?? []
} else if let tableView = view as? InstantPageV2TableView {
var result: [InstantPageV2View] = []
if let titleSubView = tableView.titleSubView {
result.append(titleSubView)
}
result.append(contentsOf: tableView.cellSubViews)
return result
}
return []
}
/// Returns the input view typed-updated against `item`, or `nil` if the existing view's
/// concrete class doesn't match the item's case (e.g. a `text` slot has been replaced by
/// a `divider` in the new layout). Caller falls back to `makeItemView`.
private func reuse(existingView: InstantPageItemView, for item: InstantPageV2LaidOutItem, theme: InstantPageTheme, animation: ListViewItemUpdateAnimation) -> InstantPageItemView? {
switch item {
case let .text(text):
guard let v = existingView as? InstantPageV2TextView else { return nil }
v.update(item: text, theme: theme)
return v
case let .codeBlock(block):
guard let v = existingView as? InstantPageV2CodeBlockView else { return nil }
v.update(item: block, theme: theme)
return v
case let .divider(divider):
guard let v = existingView as? InstantPageV2DividerView else { return nil }
v.update(item: divider, theme: theme)
return v
case let .listMarker(marker):
guard let v = existingView as? InstantPageV2ListMarkerView else { return nil }
v.update(item: marker, theme: theme)
return v
case let .blockQuoteBar(bar):
guard let v = existingView as? InstantPageV2BlockQuoteBarView else { return nil }
v.update(item: bar, theme: theme)
return v
case let .shape(shape):
guard let v = existingView as? InstantPageV2ShapeView else { return nil }
v.update(item: shape, theme: theme)
return v
case let .mediaPlaceholder(media):
guard let v = existingView as? InstantPageV2MediaPlaceholderView else { return nil }
v.update(item: media, theme: theme)
return v
case let .details(details):
guard let v = existingView as? InstantPageV2DetailsView else { return nil }
v.update(item: details, theme: theme, renderContext: self.renderContext, animation: animation)
return v
case let .table(table):
guard let v = existingView as? InstantPageV2TableView else { return nil }
v.update(item: table, theme: theme)
return v
case let .anchor(anchor):
guard let v = existingView as? InstantPageV2AnchorView else { return nil }
v.update(item: anchor, theme: theme)
return v
case let .formula(formula):
guard let v = existingView as? InstantPageV2FormulaView else { return nil }
v.update(item: formula, theme: theme)
return v
case let .mediaImage(media):
guard let v = existingView as? InstantPageV2MediaImageView, let rc = self.renderContext else { return nil }
v.update(item: media, theme: theme, renderContext: rc)
return v
case let .mediaVideo(media):
guard let v = existingView as? InstantPageV2MediaVideoView, let rc = self.renderContext else { return nil }
v.update(item: media, theme: theme, renderContext: rc)
return v
case let .mediaMap(media):
guard let v = existingView as? InstantPageV2MediaMapView, let rc = self.renderContext else { return nil }
v.update(item: media, theme: theme, renderContext: rc)
return v
case let .mediaCoverImage(media):
guard let v = existingView as? InstantPageV2MediaCoverImageView, let rc = self.renderContext else { return nil }
v.update(item: media, theme: theme, renderContext: rc)
return v
}
}
static func stableId(for item: InstantPageV2LaidOutItem, atPosition position: Int) -> InstantPageV2StableItemId {
switch item {
case let .mediaImage(m): return .media(m.media.index)
case let .mediaVideo(m): return .media(m.media.index)
case let .mediaMap(m): return .media(m.media.index)
case let .mediaCoverImage(m): return .media(m.media.index)
case let .details(d): return .details(d.index)
case .text: return .positional(.text, position)
case .codeBlock: return .positional(.codeBlock, position)
case .divider: return .positional(.divider, position)
case .listMarker: return .positional(.listMarker, position)
case .blockQuoteBar: return .positional(.blockQuoteBar, position)
case .shape: return .positional(.shape, position)
case .mediaPlaceholder: return .positional(.mediaPlaceholder, position)
case .table: return .positional(.table, position)
case .anchor: return .positional(.anchor, position)
case .formula: return .positional(.formula, position)
}
}
private func propagateRegistryHost(to view: InstantPageItemView) {
let host = self.effectiveRegistryHost
if let details = view as? InstantPageV2DetailsView {
details.forEachSubLayoutView { sub in
sub.rootMediaRegistryHost = host
}
}
if let table = view as? InstantPageV2TableView {
table.forEachSubLayoutView { sub in
sub.rootMediaRegistryHost = host
}
}
}
/// Looks up the wrapper view registered under `media.index` and returns gallery transition
/// arguments backed by its wrapped `InstantPageImageNode`. Returns `nil` if the wrapper is
/// not currently registered (e.g. the media is inside a collapsed details block).
func transitionArgsFor(_ media: InstantPageMedia, addToTransitionSurface: @escaping (UIView) -> Void) -> GalleryTransitionArguments? {
guard let wrapperBox = self.trueRegistryRoot.mediaRegistry[media.index], let wrapper = wrapperBox.value else {
return nil
}
let imageNode: InstantPageImageNode? =
(wrapper as? InstantPageV2MediaImageView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaVideoView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaMapView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaCoverImageView)?.wrappedNode
guard let imageNode else { return nil }
guard let transitionNode = imageNode.transitionNode(media: media) else { return nil }
return GalleryTransitionArguments(transitionNode: transitionNode, addToTransitionSurface: addToTransitionSurface)
}
/// Forwards a hidden-media tick from the gallery's `hiddenMedia` signal to every registered
/// wrapper, calling `updateHiddenMedia(media:)` on each wrapped image node.
func applyHiddenMedia(_ hidden: InstantPageMedia?) {
for (_, weakBox) in self.trueRegistryRoot.mediaRegistry {
guard let wrapper = weakBox.value else { continue }
if let v = wrapper as? InstantPageV2MediaImageView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaVideoView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaMapView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaCoverImageView { v.wrappedNode.updateHiddenMedia(media: hidden) }
}
}
private func makeItemView(for item: InstantPageV2LaidOutItem, theme: InstantPageTheme) -> InstantPageItemView? {
switch item {
case let .text(text):
return InstantPageV2TextView(item: text)
case let .divider(divider):
return InstantPageV2DividerView(item: divider)
case let .anchor(anchor):
return InstantPageV2AnchorView(item: anchor)
case let .listMarker(marker):
return InstantPageV2ListMarkerView(item: marker)
case let .codeBlock(block):
return InstantPageV2CodeBlockView(item: block)
case let .blockQuoteBar(bar):
return InstantPageV2BlockQuoteBarView(item: bar)
case let .shape(shape):
return InstantPageV2ShapeView(item: shape)
case let .mediaPlaceholder(media):
return InstantPageV2MediaPlaceholderView(item: media, theme: theme)
case let .details(details):
let view = InstantPageV2DetailsView(item: details, theme: theme, renderContext: self.renderContext)
view.onTitleTapped = { [weak self] index in
self?.detailsTapped?(index)
}
return view
case let .table(table):
return InstantPageV2TableView(item: table, theme: theme, renderContext: self.renderContext)
case let .mediaImage(media):
if let renderContext = self.renderContext {
return InstantPageV2MediaImageView(item: media, renderContext: renderContext, theme: theme)
} else {
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
}
case let .mediaVideo(media):
if let renderContext = self.renderContext {
return InstantPageV2MediaVideoView(item: media, renderContext: renderContext, theme: theme)
} else {
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
}
case let .mediaMap(media):
if let renderContext = self.renderContext {
return InstantPageV2MediaMapView(item: media, renderContext: renderContext, theme: theme)
} else {
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
}
case let .mediaCoverImage(media):
if let renderContext = self.renderContext {
return InstantPageV2MediaCoverImageView(item: media, renderContext: renderContext, theme: theme)
} else {
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
}
case let .formula(formula):
return InstantPageV2FormulaView(item: formula)
}
}
/// Returns the frame the parent should assign to the view for `item`.
///
/// For most item types this is `item.frame`. `InstantPageV2TextView` widens its backing store
/// by `v2TextViewClippingInset` on every side to accommodate glyph overhang and underline
/// rendering past the text's logical `maxY` the same inset its `init` applies when
/// constructing the view. The reuse path must apply the same expansion so that re-layout
/// (theme change, bubble resize, etc.) does not clip italic glyphs or underlines.
///
/// Keep this helper aligned with each view class's init-time frame computation.
private static func actualFrame(forItem item: InstantPageV2LaidOutItem) -> CGRect {
switch item {
case let .text(textItem):
return textItem.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
default:
return item.frame
}
}
}
// MARK: - Placeholder fallbacks for the four typed media items
//
// Used by `makeItemView` when `renderContext == nil` (the zero-arg V2View constructor):
// we still need to emit a sized grey rectangle for image/video/map/coverImage so the
// surrounding layout doesn't collapse. Each helper synthesizes a placeholder item with
// the same frame + cornerRadius as the typed item, picking the kind that matches the
// closest existing placeholder visual.
private func placeholderFallback(for item: InstantPageV2MediaImageItem) -> InstantPageV2MediaPlaceholderItem {
return InstantPageV2MediaPlaceholderItem(frame: item.frame, kind: .image, cornerRadius: item.cornerRadius)
}
private func placeholderFallback(for item: InstantPageV2MediaVideoItem) -> InstantPageV2MediaPlaceholderItem {
return InstantPageV2MediaPlaceholderItem(frame: item.frame, kind: .video, cornerRadius: item.cornerRadius)
}
private func placeholderFallback(for item: InstantPageV2MediaMapItem) -> InstantPageV2MediaPlaceholderItem {
return InstantPageV2MediaPlaceholderItem(frame: item.frame, kind: .map, cornerRadius: item.cornerRadius)
}
private func placeholderFallback(for item: InstantPageV2MediaCoverImageItem) -> InstantPageV2MediaPlaceholderItem {
return InstantPageV2MediaPlaceholderItem(frame: item.frame, kind: .webEmbed, cornerRadius: item.cornerRadius)
}
// MARK: - Item view protocol
protocol InstantPageItemView: UIView {
/// Frame in the parent V2 view's coordinate space (== `item.frame`).
var itemFrame: CGRect { get }
/// Recursion hook for nested layouts (details body, table cells, table title).
var subLayoutView: InstantPageV2View? { get }
}
extension InstantPageItemView {
var subLayoutView: InstantPageV2View? { return nil }
}
// MARK: - Text view (port of V1 InstantPageTextItem.drawInTile)
/// Per-side padding applied to `InstantPageV2TextView`'s backing store, beyond the
/// item's typographic frame. Marker rounded rects extend ±2pt past their run, italic
/// or accented glyphs can overhang past the line's advance width, and the last line's
/// underline sits 2pt below `lineFrame.maxY`. The view grows by this amount on each
/// side and the draw context translates by the same amount so visual position is
/// unchanged.
private let v2TextViewClippingInset: CGFloat = 4.0
final class InstantPageV2TextView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2TextItem
var itemFrame: CGRect { return self.item.frame }
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?
private var displayContentsUnderSpoilers: Bool = false
var enableSpoilerAnimations: Bool = true {
didSet {
if oldValue != self.enableSpoilerAnimations, let dustNode = self.dustNode {
self.dustNode = nil
dustNode.view.removeFromSuperview()
self.updateSpoiler(animated: false)
}
}
}
// Reveal mask state populated in Task 5.
private var maxCharacterDrawCount: Int?
private var previousMaxCharacterDrawCount: Int = 0
private var revealMaskLayer: SimpleLayer?
private var revealLineMaskLayers: [SimpleLayer] = []
private var animatingSnippetLayers: [SnippetLayer] = []
init(item: InstantPageV2TextItem) {
self.item = item
self.renderContainer = UIView()
self.renderView = TextRenderView(item: item)
super.init(frame: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset))
self.backgroundColor = .clear
self.isOpaque = false
self.renderContainer.frame = self.bounds
self.renderContainer.backgroundColor = .clear
self.renderContainer.isOpaque = false
self.addSubview(self.renderContainer)
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)
self.spoilerContainerView.frame = self.bounds
self.spoilerContainerView.isUserInteractionEnabled = false
self.addSubview(self.spoilerContainerView)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(item: InstantPageV2TextItem, theme: InstantPageTheme) {
let _ = theme
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
self.updateSpoiler(animated: false)
}
private func spoilerRectsInContainer() -> [CGRect] {
let textItem = self.item.textItem
let boundsWidth = textItem.frame.size.width
var rects: [CGRect] = []
for line in textItem.lines {
if line.spoilerItems.isEmpty { continue }
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
for spoiler in line.spoilerItems {
let localX = lineFrame.minX + (spoiler.frame.minX - line.frame.minX)
rects.append(CGRect(x: localX + v2TextViewClippingInset, y: spoiler.frame.minY + v2TextViewClippingInset, width: spoiler.frame.width, height: spoiler.frame.height))
}
}
return rects
}
func updateSpoiler(animated: Bool) {
let rects = self.spoilerRectsInContainer()
if rects.isEmpty || self.displayContentsUnderSpoilers {
if let dustNode = self.dustNode {
self.dustNode = nil
dustNode.view.removeFromSuperview()
}
return
}
let dustNode: InvisibleInkDustNode
if let current = self.dustNode {
dustNode = current
} else {
dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: self.enableSpoilerAnimations)
self.dustNode = dustNode
self.spoilerContainerView.addSubview(dustNode.view)
}
var color: UIColor = UIColor(white: 0.0, alpha: 1.0)
let textItem = self.item.textItem
if let firstRange = textItem.lines.first(where: { !$0.spoilerItems.isEmpty })?.spoilerItems.first?.range, firstRange.location < textItem.attributedString.length {
if let fg = textItem.attributedString.attribute(.foregroundColor, at: firstRange.location, effectiveRange: nil) as? UIColor {
color = fg
}
}
dustNode.view.frame = self.spoilerContainerView.bounds
dustNode.update(size: self.spoilerContainerView.bounds.size, color: color, textColor: color, rects: rects, wordRects: rects)
}
func setDisplayContentsUnderSpoilers(_ value: Bool, atLocation location: CGPoint?, animated: Bool) {
if self.displayContentsUnderSpoilers == value {
return
}
self.displayContentsUnderSpoilers = value
self.renderView.displayContentsUnderSpoilers = value
self.renderView.setNeedsDisplay()
if value {
if let dustNode = self.dustNode {
if let location, animated {
dustNode.revealAtLocation(self.convert(location, to: self.spoilerContainerView))
} else {
dustNode.update(revealed: true, animated: animated)
}
}
} else {
self.updateSpoiler(animated: animated)
}
}
func updateRevealCharacterCount(value: Int?, animated: Bool) {
if self.maxCharacterDrawCount == value {
return
}
self.maxCharacterDrawCount = value
self.updateRevealMask(animateNewSegments: animated)
}
var currentRevealCharacterCount: Int? {
return self.maxCharacterDrawCount
}
private struct RevealLineInfo {
let lineFrame: CGRect
let lineHeight: CGFloat
let revealedWidth: CGFloat
let isFull: Bool
let isRTL: Bool
}
private func computeRevealedLines(characterLimit: Int) -> [RevealLineInfo] {
var result: [RevealLineInfo] = []
var remainingCharacters = characterLimit
let textItem = self.item.textItem
let boundsWidth = textItem.frame.size.width
for line in textItem.lines {
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
// Translate from textItem-local to renderView-local coords (renderView's draw(_) translates by the inset).
let renderLocalLineFrame = lineFrame.offsetBy(dx: v2TextViewClippingInset, dy: v2TextViewClippingInset)
let lineHeight = lineFrame.size.height
guard let characterRects = line.characterRects else {
let revealedWidth: CGFloat = remainingCharacters > 0 ? renderLocalLineFrame.width : 0.0
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: revealedWidth, isFull: remainingCharacters > 0, isRTL: line.isRTL))
if remainingCharacters > 0 {
remainingCharacters -= line.range.length
}
continue
}
if remainingCharacters <= 0 {
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: 0.0, isFull: false, isRTL: line.isRTL))
continue
}
let revealCount = min(characterRects.count, remainingCharacters)
var revealedWidth: CGFloat = 0.0
if line.isRTL {
var minX: CGFloat = .greatestFiniteMagnitude
for j in 0 ..< revealCount {
let rect = characterRects[j]
if !rect.isEmpty {
minX = min(minX, rect.minX)
}
}
if minX != .greatestFiniteMagnitude {
revealedWidth = ceil(renderLocalLineFrame.width - minX)
}
} else {
for j in 0 ..< revealCount {
let rect = characterRects[j]
if !rect.isEmpty {
revealedWidth = max(revealedWidth, rect.maxX)
}
}
revealedWidth = ceil(revealedWidth)
}
remainingCharacters -= characterRects.count
let isFull = remainingCharacters >= 0
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: revealedWidth, isFull: isFull, isRTL: line.isRTL))
}
return result
}
private func updateRevealMask(animateNewSegments: Bool) {
let textItem = self.item.textItem
let lines = textItem.lines
let boundsWidth = textItem.frame.size.width
let layerSize = self.renderContainer.bounds.size
let effectiveCharacterDrawCount: Int
if let maxCharacterDrawCount = self.maxCharacterDrawCount {
effectiveCharacterDrawCount = maxCharacterDrawCount
} else {
if self.previousMaxCharacterDrawCount > 0 || !self.animatingSnippetLayers.isEmpty {
var totalCharCount = 0
for line in lines {
if let characterRects = line.characterRects {
totalCharCount += characterRects.count
} else {
totalCharCount += line.range.length
}
}
effectiveCharacterDrawCount = totalCharCount
} else {
if let _ = self.revealMaskLayer {
self.renderContainer.layer.mask = nil
self.revealMaskLayer = nil
self.revealLineMaskLayers.removeAll()
}
self.previousMaxCharacterDrawCount = 0
return
}
}
let revealMaskLayer: SimpleLayer
if let existing = self.revealMaskLayer {
revealMaskLayer = existing
} else {
revealMaskLayer = SimpleLayer()
revealMaskLayer.backgroundColor = UIColor.clear.cgColor
self.revealMaskLayer = revealMaskLayer
self.renderContainer.layer.mask = revealMaskLayer
}
revealMaskLayer.frame = CGRect(origin: .zero, size: layerSize)
let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount)
// Snippet spawn pass animate newly-revealed characters.
if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount,
let contents = self.renderView.layer.contents,
animateNewSegments {
let containerOrigin = self.renderContainer.frame.origin
var previousRemaining = self.previousMaxCharacterDrawCount
var currentRemaining = effectiveCharacterDrawCount
var globalCharIndex = 0
for i in 0 ..< lines.count {
let line = lines[i]
let lineInfo = currentLineInfos[i]
guard let characterRects = line.characterRects else { continue }
let lineCharCount = characterRects.count
let prevCount = min(max(0, previousRemaining), lineCharCount)
let curCount = min(max(0, currentRemaining), lineCharCount)
previousRemaining -= lineCharCount
currentRemaining -= lineCharCount
if curCount <= prevCount {
globalCharIndex += lineCharCount
continue
}
for j in prevCount ..< curCount {
let charRect = characterRects[j]
if charRect.isEmpty { continue }
let snippetRect = CGRect(
x: lineInfo.lineFrame.minX + charRect.origin.x,
y: lineInfo.lineFrame.minY,
width: charRect.width,
height: lineInfo.lineHeight
)
if snippetRect.width < 0.5 { continue }
let contentsRect = CGRect(
x: snippetRect.minX / layerSize.width,
y: snippetRect.minY / layerSize.height,
width: snippetRect.width / layerSize.width,
height: snippetRect.height / layerSize.height
)
let snippetLayer = SnippetLayer(characterIndex: globalCharIndex + j)
snippetLayer.contents = contents
snippetLayer.contentsRect = contentsRect
snippetLayer.contentsScale = self.renderView.layer.contentsScale
snippetLayer.contentsGravity = self.renderView.layer.contentsGravity
snippetLayer.frame = snippetRect.offsetBy(dx: containerOrigin.x, dy: containerOrigin.y)
self.layer.addSublayer(snippetLayer)
self.animatingSnippetLayers.append(snippetLayer)
ComponentTransition(animation: .curve(duration: 0.22, curve: .easeInOut)).animateBlur(layer: snippetLayer, fromRadius: 2.0, toRadius: 0.0)
snippetLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
snippetLayer.animatePosition(from: CGPoint(x: 0.0, y: 6.0), to: .zero, duration: 0.2, additive: true)
snippetLayer.animateScale(from: 0.5, to: 1.0, duration: 0.2, completion: { [weak self, weak snippetLayer] _ in
guard let self, let snippetLayer else { return }
snippetLayer.removeFromSuperlayer()
self.animatingSnippetLayers.removeAll(where: { $0 === snippetLayer })
self.updateRevealMask(animateNewSegments: false)
})
}
globalCharIndex += lineCharCount
}
}
// Mask rebuild when snippets are in flight, clamp to the lowest animating one
// (so the mask never exposes a char a snippet is still flying for). With no animations
// in flight, snap directly to the current target `previousMaxCharacterDrawCount`
// would lag by one call (it's updated at the end of this function) and is 0 on a fresh
// view, which would hide every char until the next tick.
let maskCharacterLimit: Int
if let lowestAnimating = self.animatingSnippetLayers.min(by: { $0.characterIndex < $1.characterIndex })?.characterIndex {
maskCharacterLimit = lowestAnimating
} else {
maskCharacterLimit = effectiveCharacterDrawCount
}
// Build mask rects from each revealed glyph's ink bbox, unioned per line.
// Per-character rects are stored in line-local CT coords (y positive-up,
// baseline-relative; rect.minY is negative for descenders); convert to
// renderContainer-local UIKit coords as:
// x = renderLocalLineFrame.minX + rect.minX
// y = renderLocalLineFrame.minY + lineAscent - rect.maxY
// where `lineAscent = lineFrame.size.height` (top of line frame baseline).
//
// Per-glyph rect captures descenders, italic overhang, accents exactly. Per
// line we accumulate the union of revealed glyphs into one mask rect (one
// CALayer sublayer per line), and consecutive fully-revealed lines collapse
// further into a single rect so a fully-revealed prefix is always one
// sublayer regardless of line count.
//
// Lines without per-character data (computeRevealCharacterRects == false on
// a non-streaming layout) fall back to a line-spanning rect, treated as a
// full line for merging.
var maskRects: [CGRect] = []
var pendingFullPrefix: CGRect? = nil
var remainingChars = maskCharacterLimit
for line in lines {
if remainingChars <= 0 {
break
}
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
let renderLocalLineFrame = lineFrame.offsetBy(dx: v2TextViewClippingInset, dy: v2TextViewClippingInset)
let lineAscent = lineFrame.size.height
let lineUnion: CGRect?
let isFullLine: Bool
if let characterRects = line.characterRects {
let revealCount = min(characterRects.count, remainingChars)
isFullLine = revealCount >= characterRects.count
var union: CGRect? = nil
for j in 0 ..< revealCount {
let rect = characterRects[j]
if rect.isEmpty {
continue
}
let glyphRect = CGRect(
x: renderLocalLineFrame.minX + rect.minX,
y: renderLocalLineFrame.minY + lineAscent - rect.maxY,
width: rect.width,
height: rect.height
)
union = union?.union(glyphRect) ?? glyphRect
}
lineUnion = union
remainingChars -= characterRects.count
} else {
// No per-character data expose the whole line.
lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil
isFullLine = remainingChars >= line.range.length
remainingChars -= line.range.length
}
guard let lineUnion else {
continue
}
if isFullLine {
pendingFullPrefix = pendingFullPrefix?.union(lineUnion) ?? lineUnion
} else {
if let pending = pendingFullPrefix {
maskRects.append(pending)
pendingFullPrefix = nil
}
maskRects.append(lineUnion)
}
}
if let pending = pendingFullPrefix {
maskRects.append(pending)
}
while self.revealLineMaskLayers.count < maskRects.count {
let childLayer = SimpleLayer()
childLayer.backgroundColor = UIColor.white.cgColor
revealMaskLayer.addSublayer(childLayer)
self.revealLineMaskLayers.append(childLayer)
}
while self.revealLineMaskLayers.count > maskRects.count {
let removed = self.revealLineMaskLayers.removeLast()
removed.removeFromSuperlayer()
}
for i in 0 ..< maskRects.count {
self.revealLineMaskLayers[i].frame = maskRects[i]
}
self.previousMaxCharacterDrawCount = effectiveCharacterDrawCount
if self.maxCharacterDrawCount == nil && self.animatingSnippetLayers.isEmpty {
self.renderContainer.layer.mask = nil
self.revealMaskLayer = nil
self.revealLineMaskLayers.removeAll()
}
}
}
private final class TextRenderView: UIView {
var item: InstantPageV2TextItem
var displayContentsUnderSpoilers: Bool = false
init(item: InstantPageV2TextItem) {
self.item = item
super.init(frame: .zero)
self.backgroundColor = .clear
self.isOpaque = false
self.contentMode = .redraw
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.saveGState()
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
context.translateBy(x: v2TextViewClippingInset, y: v2TextViewClippingInset)
if !self.displayContentsUnderSpoilers {
let textItemForClip = self.item.textItem
let clipBoundsWidth = textItemForClip.frame.size.width
var spoilerRects: [CGRect] = []
for line in textItemForClip.lines {
if line.spoilerItems.isEmpty { continue }
let lineFrame = v2FrameForLine(line, boundingWidth: clipBoundsWidth, alignment: textItemForClip.alignment)
for spoiler in line.spoilerItems {
spoilerRects.append(spoiler.frame.offsetBy(dx: lineFrame.minX - line.frame.minX, dy: 0.0))
}
}
if !spoilerRects.isEmpty {
// Even-odd subtracts each spoiler rect from the full-bounds rect. Spoiler rects never
// overlap (same-line ranges are horizontally monotonic; cross-line rects differ in y),
// so no hole is accidentally re-included by an odd crossing count.
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: textItemForClip.frame.size).insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset))
for rect in spoilerRects {
path.addRect(rect)
}
context.addPath(path)
context.clip(using: .evenOdd)
}
}
let textItem = self.item.textItem
let boundsWidth = textItem.frame.size.width
let intersectRect = rect.offsetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
for line in textItem.lines {
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
if !intersectRect.intersects(lineFrame) {
continue
}
let lineOrigin = lineFrame.origin
context.textPosition = CGPoint(x: lineOrigin.x, y: lineOrigin.y + lineFrame.size.height)
if !line.markedItems.isEmpty {
context.saveGState()
for item in line.markedItems {
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
context.setFillColor(item.color.cgColor)
let height = floor(item.frame.size.height * 2.2)
let markRect = CGRect(x: itemFrame.minX - 2.0, y: floor(itemFrame.minY + (itemFrame.height - height) / 2.0), width: itemFrame.width + 4.0, height: height)
let path = UIBezierPath(roundedRect: markRect, cornerRadius: 3.0)
context.addPath(path.cgPath)
context.fillPath()
}
context.restoreGState()
}
if textItem.opaqueBackground {
context.setBlendMode(.normal)
}
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
}
}
if textItem.opaqueBackground {
context.setBlendMode(.copy)
}
if !line.strikethroughItems.isEmpty {
for item in line.strikethroughItems {
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + floor((itemFrame.size.height / 2.0) + 1.0), width: itemFrame.size.width, height: 1.0))
}
}
if !line.underlineItems.isEmpty {
for item in line.underlineItems {
var color: UIColor? = item.color
if color == nil {
textItem.attributedString.enumerateAttributes(in: item.range, options: []) { attributes, _, _ in
if let foreground = attributes[NSAttributedString.Key.foregroundColor] as? UIColor {
color = foreground
}
}
}
if let color {
context.setFillColor(color.cgColor)
}
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + itemFrame.size.height + 2.0, width: itemFrame.size.width, height: 1.0))
}
}
}
context.restoreGState()
}
}
/// Private snippet layer for the streaming reveal pop-in animation.
/// Each instance represents one character cropped from the renderView's backing texture,
/// animating in (blur + alpha + position + scale) before the mask absorbs its rect.
private final class SnippetLayer: SimpleLayer {
let characterIndex: Int
init(characterIndex: Int) {
self.characterIndex = characterIndex
super.init()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override init(layer: Any) {
if let other = layer as? SnippetLayer {
self.characterIndex = other.characterIndex
} else {
self.characterIndex = 0
}
super.init(layer: layer)
}
}
// MARK: - Divider view
final class InstantPageV2DividerView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2DividerItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2DividerItem) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = item.color
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2DividerItem, theme: InstantPageTheme) {
let _ = theme
self.item = item
self.backgroundColor = item.color
}
}
// MARK: - Anchor view (zero-height; nothing to render)
final class InstantPageV2AnchorView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2AnchorItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2AnchorItem) {
self.item = item
super.init(frame: item.frame)
self.isHidden = true
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2AnchorItem, theme: InstantPageTheme) {
let _ = theme
self.item = item
}
}
// MARK: - List marker view
final class InstantPageV2ListMarkerView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2ListMarkerItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2ListMarkerItem) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = .clear
self.isOpaque = false
self.rebuildContents()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2ListMarkerItem, theme: InstantPageTheme) {
let _ = theme
self.item = item
self.rebuildContents()
}
private func rebuildContents() {
for subview in Array(self.subviews) {
subview.removeFromSuperview()
}
if let sublayers = self.layer.sublayers {
for sublayer in Array(sublayers) {
sublayer.removeFromSuperlayer()
}
}
let item = self.item
switch item.kind {
case .bullet:
let radius: CGFloat = min(item.frame.width, item.frame.height) / 2.0
let dot = CALayer()
dot.backgroundColor = item.color.cgColor
dot.frame = CGRect(
x: (item.frame.width - radius * 2.0) / 2.0,
y: (item.frame.height - radius * 2.0) / 2.0,
width: radius * 2.0,
height: radius * 2.0
)
dot.cornerRadius = radius
self.layer.addSublayer(dot)
case let .number(text):
let label = UILabel()
label.text = text
label.textColor = item.color
label.font = UIFont.systemFont(ofSize: 17.0)
label.textAlignment = .right
label.frame = CGRect(origin: .zero, size: item.frame.size)
self.addSubview(label)
case let .checklist(checked, colors):
let checkNodeTheme = CheckNodeTheme(
backgroundColor: colors.background,
strokeColor: colors.stroke,
borderColor: colors.border,
overlayBorder: false,
hasInset: false,
hasShadow: false
)
let checkNode = CheckNode(theme: checkNodeTheme, content: .check(isRectangle: true))
checkNode.displaysAsynchronously = false
checkNode.isUserInteractionEnabled = false
checkNode.frame = CGRect(origin: .zero, size: item.frame.size)
checkNode.setSelected(checked, animated: false)
self.addSubview(checkNode.view)
}
}
}
// MARK: - Quote bar view
final class InstantPageV2BlockQuoteBarView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2BarItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2BarItem) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = item.color
self.layer.cornerRadius = item.cornerRadius
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2BarItem, theme: InstantPageTheme) {
let _ = theme
self.item = item
self.backgroundColor = item.color
self.layer.cornerRadius = item.cornerRadius
}
}
// MARK: - Shape view (for pullQuote line ornaments)
final class InstantPageV2ShapeView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2ShapeItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2ShapeItem) {
self.item = item
super.init(frame: item.frame)
self.applyKind()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2ShapeItem, theme: InstantPageTheme) {
let _ = theme
self.item = item
self.applyKind()
}
private func applyKind() {
switch self.item.kind {
case let .roundedRect(cornerRadius):
self.backgroundColor = self.item.color
self.layer.cornerRadius = cornerRadius
case .line(_):
self.backgroundColor = self.item.color
self.layer.cornerRadius = 0.0
}
}
}
// MARK: - Media placeholder view (V0: gray rectangle)
final class InstantPageV2MediaPlaceholderView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2MediaPlaceholderItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2MediaPlaceholderItem, theme: InstantPageTheme) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = theme.imageTintColor?.withAlphaComponent(0.2) ?? UIColor(white: 0.85, alpha: 1.0)
self.layer.cornerRadius = item.cornerRadius
self.clipsToBounds = item.cornerRadius > 0.0
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2MediaPlaceholderItem, theme: InstantPageTheme) {
self.item = item
self.backgroundColor = theme.imageTintColor?.withAlphaComponent(0.2) ?? UIColor(white: 0.85, alpha: 1.0)
self.layer.cornerRadius = item.cornerRadius
self.clipsToBounds = item.cornerRadius > 0.0
}
}
// MARK: - Details view
final class InstantPageV2DetailsView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2DetailsItem
var itemFrame: CGRect { return self.item.frame }
let titleTextView: InstantPageV2TextView
private let chevronView: UIImageView
private let separator: UIView
var bodyView: InstantPageV2View?
private let titleHitView: UIView
// The expanded chevron is the collapsed one rotated 180° (down up).
private static let expandedChevronTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
// On an animated collapse the body is kept until the toggle animation finishes (so the
// shrinking clip can hide it), then removed in finalizePendingCollapse() which the parent
// (InstantPageV2View.update) calls from the completion of the frame-shrink (clip) animation.
private var bodyPendingRemoval = false
var onTitleTapped: ((Int) -> Void)?
var subLayoutView: InstantPageV2View? { return self.bodyView }
func forEachSubLayoutView(_ body: (InstantPageV2View) -> Void) {
if let bodyView = self.bodyView { body(bodyView) }
}
init(item: InstantPageV2DetailsItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext?) {
self.item = item
let titleV2Item = InstantPageV2TextItem(
frame: item.titleTextItem.frame,
textItem: item.titleTextItem
)
self.titleTextView = InstantPageV2TextView(item: titleV2Item)
self.titleTextView.isUserInteractionEnabled = false
self.chevronView = UIImageView()
// Single downward chevron; the expanded state is a 180° rotation (animatable) rather than
// an instant chevron.up/chevron.down image swap. A template image + tintColor renders the
// SF Symbol in the message's primary text color baking the color into a CALayer's cgImage
// contents drops the tint and renders black. (SF Symbol is iOS 13+.)
self.chevronView.image = UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate)
self.chevronView.tintColor = theme.textCategories.paragraph.color
self.chevronView.contentMode = .scaleAspectFit
// Decorative: let taps fall through to titleHitView (which carries the toggle gesture).
self.chevronView.isUserInteractionEnabled = false
self.separator = UIView()
self.separator.backgroundColor = item.separatorColor
self.separator.isUserInteractionEnabled = false
self.titleHitView = UIView(frame: item.titleFrame)
self.titleHitView.backgroundColor = .clear
super.init(frame: item.frame)
self.backgroundColor = .clear
self.clipsToBounds = true
self.addSubview(self.titleTextView)
self.addSubview(self.chevronView)
self.addSubview(self.separator)
let chevronSize = CGSize(width: 18.0, height: 18.0)
// bounds + center (not frame) so the rotation transform pivots around the center and the
// frame stays well-defined while a non-identity transform is applied.
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
self.chevronView.center = CGPoint(
x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0,
y: item.titleFrame.midY
)
self.chevronView.layer.transform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
// V1 (InstantPageDetailsNode.swift:138): separator sits at titleHeight - UIScreenPixel.
self.separator.frame = CGRect(
x: 0.0,
y: item.titleFrame.maxY - 0.5,
width: item.frame.width,
height: 0.5
)
if item.isExpanded, let innerLayout = item.innerLayout {
let body = InstantPageV2View(renderContext: renderContext)
body.update(layout: innerLayout, theme: theme, animation: .None)
body.frame = CGRect(
origin: CGPoint(x: 0.0, y: item.titleFrame.maxY),
size: innerLayout.contentSize
)
self.addSubview(body)
self.bodyView = body
}
let tap = UITapGestureRecognizer(target: self, action: #selector(self.titleTapped))
self.insertSubview(self.titleHitView, at: 0)
self.titleHitView.addGestureRecognizer(tap)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
@objc private func titleTapped() {
self.onTitleTapped?(self.item.index)
}
func update(item: InstantPageV2DetailsItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext?, animation: ListViewItemUpdateAnimation) {
self.item = item
let titleV2Item = InstantPageV2TextItem(
frame: item.titleTextItem.frame,
textItem: item.titleTextItem
)
self.titleTextView.update(item: titleV2Item, theme: theme)
self.chevronView.tintColor = theme.textCategories.paragraph.color
let chevronSize = CGSize(width: 18.0, height: 18.0)
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
self.chevronView.center = CGPoint(
x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0,
y: item.titleFrame.midY
)
self.separator.backgroundColor = item.separatorColor
self.separator.frame = CGRect(
x: 0.0,
y: item.titleFrame.maxY - 0.5,
width: item.frame.width,
height: 0.5
)
self.titleHitView.frame = item.titleFrame
// Body lifecycle. The reveal/hide of the body is produced by the parent animating this
// view's own frame height (clipsToBounds = true), not by the body itself see
// InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
// *nested* details block inside the body can also animate its own toggle.
if item.isExpanded {
if let innerLayout = item.innerLayout {
let body: InstantPageV2View
if let existingBody = self.bodyView {
// Reuse: covers expandedexpanded content updates and a re-expand that
// interrupts a still-pending collapse removal.
body = existingBody
self.bodyPendingRemoval = false
} else {
body = InstantPageV2View(renderContext: renderContext)
self.addSubview(body)
self.bodyView = body
}
body.update(layout: innerLayout, theme: theme, animation: animation)
body.frame = CGRect(
origin: CGPoint(x: 0.0, y: item.titleFrame.maxY),
size: innerLayout.contentSize
)
}
} else {
if let existingBody = self.bodyView {
if animation.isAnimated {
// Keep the body visible while the parent's frame-shrink clips it away;
// it is removed in finalizePendingCollapse() (called from that clip animation's
// completion in InstantPageV2View.update).
self.bodyPendingRemoval = true
} else {
existingBody.removeFromSuperview()
self.bodyView = nil
}
}
}
// Chevron rotation. The body teardown on collapse is NOT tied to this completion see
// finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
animation.animator.updateTransform(layer: self.chevronView.layer, transform: targetTransform, completion: nil)
}
/// Removes the body kept alive across an animated collapse. The parent (InstantPageV2View.update)
/// calls this from the completion of the frame-shrink animation that clips the body away, so the
/// body is torn down exactly when it finishes being hidden. The guard makes a re-expand that
/// interrupts the collapse safe the re-expand clears `bodyPendingRemoval` first.
func finalizePendingCollapse() {
if !self.item.isExpanded, self.bodyPendingRemoval {
self.bodyView?.removeFromSuperview()
self.bodyView = nil
self.bodyPendingRemoval = false
}
}
}
// MARK: - Code block view
final class InstantPageV2CodeBlockView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2CodeBlockItem
var itemFrame: CGRect { return self.item.frame }
private let backgroundLayer: CALayer
let textView: InstantPageV2TextView
init(item: InstantPageV2CodeBlockItem) {
self.item = item
self.backgroundLayer = CALayer()
self.backgroundLayer.backgroundColor = item.backgroundColor.cgColor
self.backgroundLayer.cornerRadius = item.cornerRadius
self.backgroundLayer.frame = CGRect(origin: .zero, size: item.frame.size)
// item.textItem.frame is already in code-block content-area coords (x=17, y=backgroundInset).
let innerV2TextItem = InstantPageV2TextItem(
frame: item.textItem.frame,
textItem: item.textItem
)
self.textView = InstantPageV2TextView(item: innerV2TextItem)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.layer.addSublayer(self.backgroundLayer)
self.addSubview(self.textView)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2CodeBlockItem, theme: InstantPageTheme) {
self.item = item
self.backgroundLayer.backgroundColor = item.backgroundColor.cgColor
self.backgroundLayer.cornerRadius = item.cornerRadius
self.backgroundLayer.frame = CGRect(origin: .zero, size: item.frame.size)
let innerV2TextItem = InstantPageV2TextItem(
frame: item.textItem.frame,
textItem: item.textItem
)
self.textView.update(item: innerV2TextItem, theme: theme)
}
}
// MARK: - Table view
final class InstantPageV2TableView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2TableItem
var itemFrame: CGRect { return self.item.frame }
private let scrollView: UIScrollView
let contentView: UIView
var titleSubView: InstantPageV2View?
var cellSubViews: [InstantPageV2View] = []
private var stripeLayers: [CALayer] = []
private var lineLayers: [CALayer] = []
var subLayoutView: InstantPageV2View? { return nil }
func forEachSubLayoutView(_ body: (InstantPageV2View) -> Void) {
if let titleView = self.titleSubView { body(titleView) }
for cellView in self.cellSubViews { body(cellView) }
}
init(item: InstantPageV2TableItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext?) {
self.item = item
self.scrollView = UIScrollView()
self.contentView = UIView()
super.init(frame: item.frame)
self.backgroundColor = .clear
self.scrollView.frame = self.bounds
self.scrollView.contentSize = item.contentSize
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = false
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width > item.frame.width
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.disablesInteractiveTransitionGestureRecognizer = true
self.addSubview(self.scrollView)
self.contentView.frame = CGRect(origin: .zero, size: item.contentSize)
self.scrollView.addSubview(self.contentView)
// Title sub-layout (above the grid, inside the scroll view's content).
if let titleLayout = item.titleSubLayout, let titleFrame = item.titleFrame {
let v = InstantPageV2View(renderContext: renderContext)
v.update(layout: titleLayout, theme: theme, animation: .None)
v.frame = CGRect(x: v2TableCellInsets.left, y: titleFrame.minY + v2TableCellInsets.top,
width: titleLayout.contentSize.width, height: titleLayout.contentSize.height)
self.contentView.addSubview(v)
self.titleSubView = v
}
// Grid origin: shifted down by title height when present.
let gridOffsetY = item.titleFrame?.height ?? 0.0
// Cell backgrounds and sub-layouts.
for cell in item.cells {
if let bg = cell.backgroundColor {
let stripe = CALayer()
stripe.backgroundColor = bg.cgColor
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
self.contentView.layer.insertSublayer(stripe, at: 0)
self.stripeLayers.append(stripe)
}
if let subLayout = cell.subLayout {
let v = InstantPageV2View(renderContext: renderContext)
v.update(layout: subLayout, theme: theme, animation: .None)
// The sub-layout items are already offset by cell insets inside the cell frame.
v.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
self.contentView.addSubview(v)
self.cellSubViews.append(v)
}
}
// Border lines.
if item.bordered {
for r in item.horizontalLines + item.verticalLines {
let line = CALayer()
line.backgroundColor = item.borderColor.cgColor
line.frame = r.offsetBy(dx: 0.0, dy: gridOffsetY)
self.contentView.layer.addSublayer(line)
self.lineLayers.append(line)
}
// Outer border rect (four edges).
let outerW = v2TableBorderWidth
let outerRect = CGRect(
x: outerW / 2.0,
y: gridOffsetY + outerW / 2.0,
width: item.contentSize.width - outerW,
height: item.contentSize.height - outerW
)
let outerEdges: [CGRect] = [
CGRect(x: outerRect.minX, y: outerRect.minY, width: outerRect.width, height: outerW),
CGRect(x: outerRect.minX, y: outerRect.maxY - outerW, width: outerRect.width, height: outerW),
CGRect(x: outerRect.minX, y: outerRect.minY, width: outerW, height: outerRect.height),
CGRect(x: outerRect.maxX - outerW, y: outerRect.minY, width: outerW, height: outerRect.height)
]
for edge in outerEdges {
let line = CALayer()
line.backgroundColor = item.borderColor.cgColor
line.frame = edge
self.contentView.layer.addSublayer(line)
self.lineLayers.append(line)
}
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2TableItem, theme: InstantPageTheme) {
self.item = item
self.scrollView.frame = CGRect(origin: .zero, size: item.frame.size)
self.scrollView.contentSize = item.contentSize
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width > item.frame.width
self.contentView.frame = CGRect(origin: .zero, size: item.contentSize)
// Forward updates to nested V2 sub-layouts (title + each cell). Recursive update
// propagation. Cell-count or title-presence changes fall back to rebuild via the
// V2View's internal `update(layout:theme:animation:)` (task B refines).
if let titleLayout = item.titleSubLayout, let titleView = self.titleSubView, let titleFrame = item.titleFrame {
titleView.update(layout: titleLayout, theme: theme, animation: .None)
titleView.frame = CGRect(
x: v2TableCellInsets.left,
y: titleFrame.minY + v2TableCellInsets.top,
width: titleLayout.contentSize.width,
height: titleLayout.contentSize.height
)
}
let gridOffsetY = item.titleFrame?.height ?? 0.0
var cellLayoutIndex = 0
for cell in item.cells {
if let subLayout = cell.subLayout, cellLayoutIndex < self.cellSubViews.count {
let cellView = self.cellSubViews[cellLayoutIndex]
cellView.update(layout: subLayout, theme: theme, animation: .None)
cellView.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
cellLayoutIndex += 1
}
}
// Stripe layers (cell backgrounds) update color + frame in original order.
var stripeIndex = 0
for cell in item.cells {
if let bg = cell.backgroundColor, stripeIndex < self.stripeLayers.count {
let stripe = self.stripeLayers[stripeIndex]
stripe.backgroundColor = bg.cgColor
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
stripeIndex += 1
}
}
// Line layers (borders) update color in place; frames recomputed in original order.
for line in self.lineLayers {
line.backgroundColor = item.borderColor.cgColor
}
}
}
// MARK: - Public helpers on InstantPageV2View
public extension InstantPageV2View {
func lastTextLineFrame() -> CGRect? {
guard let layout = self.currentLayout else { return nil }
return InstantPageUI.lastTextLineFrame(in: layout)
}
func textItemAt(point: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? {
guard let layout = self.currentLayout else { return nil }
return findTextItem(in: layout, point: point, accumulatedOffset: .zero)
}
func urlItemAt(point: CGPoint) -> (urlItem: InstantPageUrlItem, item: InstantPageTextItem,
parentOffset: CGPoint, localPoint: CGPoint)? {
guard let hit = self.textItemAt(point: point) else { return nil }
let localPoint = CGPoint(x: point.x - hit.parentOffset.x, y: point.y - hit.parentOffset.y)
guard let url = hit.item.urlAttribute(at: localPoint) else { return nil }
return (urlItem: url, item: hit.item, parentOffset: hit.parentOffset, localPoint: localPoint)
}
func selectableTextItems() -> [(item: InstantPageTextItem, parentOffset: CGPoint)] {
guard let layout = self.currentLayout else { return [] }
var result: [(InstantPageTextItem, CGPoint)] = []
collectSelectableTextItems(in: layout, accumulatedOffset: .zero, into: &result)
return result.map { (item: $0.0, parentOffset: $0.1) }
}
func detailsItem(atIndex index: Int) -> (frame: CGRect, titleFrame: CGRect)? {
guard let layout = self.currentLayout else { return nil }
for item in layout.items {
if case let .details(d) = item, d.index == index {
return (frame: d.frame, titleFrame: d.titleFrame.offsetBy(dx: d.frame.minX, dy: d.frame.minY))
}
}
return nil
}
}
// MARK: - Private recursion helpers
private func findTextItem(
in layout: InstantPageV2Layout,
point: CGPoint,
accumulatedOffset: CGPoint
) -> (item: InstantPageTextItem, parentOffset: CGPoint)? {
for item in layout.items {
let f = item.frame.offsetBy(dx: accumulatedOffset.x, dy: accumulatedOffset.y)
if !f.contains(point) { continue }
switch item {
case let .text(text):
return (item: text.textItem, parentOffset: CGPoint(x: f.minX, y: f.minY))
case let .codeBlock(block):
let textOrigin = CGPoint(
x: f.minX + block.textItem.frame.minX,
y: f.minY + block.textItem.frame.minY
)
return (item: block.textItem, parentOffset: textOrigin)
case let .details(details):
if details.titleFrame.offsetBy(dx: f.minX, dy: f.minY).contains(point) {
let titleOrigin = CGPoint(
x: f.minX + details.titleTextItem.frame.minX,
y: f.minY + details.titleTextItem.frame.minY
)
return (item: details.titleTextItem, parentOffset: titleOrigin)
}
if let inner = details.innerLayout {
let innerOffset = CGPoint(x: f.minX, y: f.minY + details.titleFrame.maxY)
if let hit = findTextItem(in: inner, point: point, accumulatedOffset: innerOffset) {
return hit
}
}
case let .table(table):
for cell in table.cells {
let cellAbs = cell.frame.offsetBy(dx: f.minX, dy: f.minY)
if !cellAbs.contains(point) { continue }
if let sub = cell.subLayout {
if let hit = findTextItem(in: sub, point: point,
accumulatedOffset: CGPoint(x: cellAbs.minX, y: cellAbs.minY)) {
return hit
}
}
}
if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame {
let titleAbs = titleFrame.offsetBy(dx: f.minX, dy: f.minY)
if titleAbs.contains(point) {
if let hit = findTextItem(in: titleLayout, point: point,
accumulatedOffset: CGPoint(x: titleAbs.minX, y: titleAbs.minY)) {
return hit
}
}
}
default:
continue
}
}
return nil
}
private func collectSelectableTextItems(
in layout: InstantPageV2Layout,
accumulatedOffset: CGPoint,
into result: inout [(InstantPageTextItem, CGPoint)]
) {
for item in layout.items {
switch item {
case let .text(text):
if text.textItem.selectable && !text.textItem.attributedString.string.isEmpty {
result.append((text.textItem, CGPoint(
x: accumulatedOffset.x + text.frame.minX,
y: accumulatedOffset.y + text.frame.minY
)))
}
case let .codeBlock(block):
if block.textItem.selectable && !block.textItem.attributedString.string.isEmpty {
result.append((block.textItem, CGPoint(
x: accumulatedOffset.x + block.frame.minX + block.textItem.frame.minX,
y: accumulatedOffset.y + block.frame.minY + block.textItem.frame.minY
)))
}
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,
y: accumulatedOffset.y + details.frame.minY + details.titleFrame.maxY
)
collectSelectableTextItems(in: inner, accumulatedOffset: innerOffset, into: &result)
}
case let .table(table):
if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame {
let titleOffset = CGPoint(
x: accumulatedOffset.x + table.frame.minX + titleFrame.minX,
y: accumulatedOffset.y + table.frame.minY + titleFrame.minY
)
collectSelectableTextItems(in: titleLayout, accumulatedOffset: titleOffset, into: &result)
}
for cell in table.cells {
if let sub = cell.subLayout {
let cellOffset = CGPoint(
x: accumulatedOffset.x + table.frame.minX + cell.frame.minX,
y: accumulatedOffset.y + table.frame.minY + cell.frame.minY
)
collectSelectableTextItems(in: sub, accumulatedOffset: cellOffset, into: &result)
}
}
default:
continue
}
}
}
// MARK: - Formula view
/// Renders both block (`InstantPageBlock.formula(latex:)`) and inline (`InstantPageFormulaAttribute`)
/// math, sourcing the pre-rendered Retina image from `InstantPageMathAttachment.rendered`.
///
/// For block formulas wider than the bubble's available width, the layout sets
/// `isScrollable = true`; this view then wraps the image in a horizontal `UIScrollView`
/// matching V1's `InstantPageScrollableNode` (no bounce on non-overflowing content,
/// scroll indicator hidden appropriate for content embedded inside a chat bubble).
final class InstantPageV2FormulaView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2FormulaItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2FormulaItem) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = .clear
self.isOpaque = false
self.buildContents()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(item: InstantPageV2FormulaItem, theme: InstantPageTheme) {
let _ = theme
self.item = item
// Image content and scroll/non-scroll shape may change with width; rebuild.
for sub in self.subviews { sub.removeFromSuperview() }
if let sublayers = self.layer.sublayers {
for layer in sublayers { layer.removeFromSuperlayer() }
}
self.buildContents()
}
private func buildContents() {
let item = self.item
let imageLayer = CALayer()
imageLayer.contents = item.attachment.rendered.image.cgImage
imageLayer.contentsScale = item.attachment.rendered.image.scale
imageLayer.contentsGravity = .resizeAspect
imageLayer.frame = item.imageFrame
if item.isScrollable {
self.clipsToBounds = true
self.isUserInteractionEnabled = true
let scroll = UIScrollView(frame: CGRect(origin: .zero, size: item.frame.size))
scroll.autoresizingMask = [.flexibleWidth, .flexibleHeight]
scroll.contentSize = item.scrollContentSize
scroll.showsHorizontalScrollIndicator = false
scroll.showsVerticalScrollIndicator = false
scroll.alwaysBounceHorizontal = false
scroll.alwaysBounceVertical = false
scroll.contentInsetAdjustmentBehavior = .never
scroll.disablesInteractiveTransitionGestureRecognizer = true
self.addSubview(scroll)
// Layers don't autoresize with their superview; host the image layer inside a UIView
// so the scroll view's content-size growth keeps the image positioned correctly.
let imageHost = UIView(frame: CGRect(origin: .zero, size: item.scrollContentSize))
imageHost.layer.addSublayer(imageLayer)
scroll.addSubview(imageHost)
} else {
// Inline and centered-block formulas don't accept touches; the bubble's link/long-press
// recognizers run against the underlying text view instead.
self.isUserInteractionEnabled = false
self.layer.addSublayer(imageLayer)
}
}
}