mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
7ca10eb91e
6 changed files with 371 additions and 55 deletions
|
|
@ -250,8 +250,16 @@ struct MessageListView: View {
|
|||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, 4)
|
||||
// 19pt bottom content inset so the ReplyBar's "+" and
|
||||
// pill sit 19pt above the *physical* screen edge when
|
||||
// the chat is scrolled to the tail. Combined with
|
||||
// .ignoresSafeArea(edges: .bottom) on the ScrollView
|
||||
// below — without that, the system bottom safe-area
|
||||
// adds extra space and the total inset overshoots.
|
||||
.padding(.bottom, 19)
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.environment(store)
|
||||
.task {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,25 @@ struct ReplyBar: View {
|
|||
let onAttachTap: () -> Void
|
||||
let onTextTap: () -> Void
|
||||
|
||||
// Circular "+" button and reply-field capsule share a single pill
|
||||
// diameter so they line up vertically and visually mirror the
|
||||
// watchOS-26 system nav back chevron. The "+" is inset 11pt from the
|
||||
// chat-content's leading edge so that, combined with the ScrollView's
|
||||
// outer .padding(.horizontal, 4), it sits 15pt from the screen edge —
|
||||
// aligned with the system back-chevron in a pushed view.
|
||||
private static let pillHeight: CGFloat = 35
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Button(action: onAttachTap) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 14))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.glassEffect()
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(width: Self.pillHeight, height: Self.pillHeight)
|
||||
.contentShape(Circle())
|
||||
.glassEffect(in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 11)
|
||||
.accessibilityIdentifier("replyBarAttach")
|
||||
|
||||
Button(action: onTextTap) {
|
||||
|
|
@ -25,7 +33,7 @@ struct ReplyBar: View {
|
|||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(height: Self.pillHeight)
|
||||
.contentShape(Rectangle())
|
||||
.glassEffect()
|
||||
}
|
||||
|
|
@ -35,3 +43,35 @@ struct ReplyBar: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Side-by-side reference for the round "+" button. SnapshotPreviews doesn't
|
||||
// render NavigationStack chrome, so the watchOS-26 system back chevron is
|
||||
// mocked (same glass-Circle shape) at its in-app position (15pt leading,
|
||||
// 6pt top) for a direct visual compare against the "+" button at the
|
||||
// ReplyBar's in-app position (15pt leading, 19pt bottom). Frame matches
|
||||
// the 46mm canvas (208×248 logical pts).
|
||||
private struct ReplyBarGeometryPreviewHost: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(width: 35, height: 35)
|
||||
.glassEffect(in: Circle())
|
||||
.padding(.leading, 15)
|
||||
.padding(.top, 6)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
|
||||
ReplyBar(onAttachTap: {}, onTextTap: {})
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.bottom, 19)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
.frame(width: 208, height: 248)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Reply bar vs nav back") { ReplyBarGeometryPreviewHost() }
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -80,6 +80,27 @@ public final class InstantPageV2RenderContext {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Inline image view data
|
||||
|
||||
private struct InlineImageKey: Hashable {
|
||||
let fileId: Int64
|
||||
let occurrenceIndex: Int
|
||||
}
|
||||
|
||||
/// Per-inline-image state owned by `InstantPageV2View`.
|
||||
/// `textView` is a weak reference back to the parent so `updateImageReveal` can
|
||||
/// look up the current per-text-view character count.
|
||||
private final class InstantPageInlineImageData {
|
||||
let view: InstantPageV2InlineImageView
|
||||
weak var textView: InstantPageV2TextView?
|
||||
var charIndexInItem: Int = 0
|
||||
var revealed: Bool = false
|
||||
|
||||
init(view: InstantPageV2InlineImageView) {
|
||||
self.view = view
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Emoji layer data
|
||||
|
||||
private final class InstantPageEmojiLayerData {
|
||||
|
|
@ -108,6 +129,7 @@ public final class InstantPageV2View: UIView {
|
|||
public let renderContext: InstantPageV2RenderContext?
|
||||
|
||||
private var inlineStickerItemLayers: [InlineStickerItemLayer.Key: InstantPageEmojiLayerData] = [:]
|
||||
private var inlineImageViews: [InlineImageKey: InstantPageInlineImageData] = [:]
|
||||
private var emojiEnableLooping: Bool = true
|
||||
|
||||
/// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping.
|
||||
|
|
@ -234,6 +256,7 @@ public final class InstantPageV2View: UIView {
|
|||
self.itemViewStableIds = newStableIds
|
||||
self.currentLayout = layout
|
||||
self.currentTheme = theme
|
||||
self.updateInlineImages()
|
||||
self.updateInlineEmoji()
|
||||
let enableSpoilerAnimations = self.renderContext.map { $0.context.sharedContext.energyUsageSettings.fullTranslucency } ?? true
|
||||
for view in self.itemViews {
|
||||
|
|
@ -332,6 +355,82 @@ public final class InstantPageV2View: UIView {
|
|||
self.updateEmojiReveal(animated: false)
|
||||
}
|
||||
|
||||
func updateInlineImages() {
|
||||
guard let renderContext = self.renderContext,
|
||||
let layout = self.currentLayout,
|
||||
let theme = self.currentTheme
|
||||
else { return }
|
||||
let context = renderContext.context
|
||||
|
||||
var nextIndexById: [Int64: Int] = [:]
|
||||
var validKeys: Set<InlineImageKey> = []
|
||||
|
||||
for view in self.itemViews {
|
||||
guard let textView = view as? InstantPageV2TextView else { continue }
|
||||
let textItem = textView.item.textItem
|
||||
let boundsWidth = textItem.frame.size.width
|
||||
for line in textItem.lines {
|
||||
if line.imageItems.isEmpty { continue }
|
||||
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
|
||||
for imageItem in line.imageItems {
|
||||
let index = nextIndexById[imageItem.id.id] ?? 0
|
||||
nextIndexById[imageItem.id.id] = index + 1
|
||||
let key = InlineImageKey(fileId: imageItem.id.id, occurrenceIndex: index)
|
||||
guard let media = layout.media[imageItem.id] else { continue }
|
||||
validKeys.insert(key)
|
||||
|
||||
let localX = lineFrame.minX + (imageItem.frame.minX - line.frame.minX)
|
||||
let itemFrame = CGRect(
|
||||
x: localX + v2TextViewClippingInset,
|
||||
y: imageItem.frame.minY + v2TextViewClippingInset,
|
||||
width: imageItem.frame.width,
|
||||
height: imageItem.frame.height
|
||||
)
|
||||
|
||||
let data: InstantPageInlineImageData
|
||||
if let existing = self.inlineImageViews[key] {
|
||||
data = existing
|
||||
if data.view.superview !== textView.imageContainerView {
|
||||
textView.imageContainerView.addSubview(data.view)
|
||||
}
|
||||
} else {
|
||||
let newView = InstantPageV2InlineImageView(
|
||||
media: media,
|
||||
webpage: layout.webpage,
|
||||
frame: itemFrame,
|
||||
context: context,
|
||||
userLocation: .other,
|
||||
theme: theme
|
||||
)
|
||||
// Image starts hidden; updateImageReveal pops it in when the streaming
|
||||
// cursor crosses its char-index. For non-streaming pages (no
|
||||
// `TypingDraftMessageAttribute`), `revealed = true` (via the fallback in
|
||||
// updateImageReveal) sets alpha = 1 unconditionally on the next call.
|
||||
newView.alpha = 0.0
|
||||
data = InstantPageInlineImageData(view: newView)
|
||||
self.inlineImageViews[key] = data
|
||||
textView.imageContainerView.addSubview(newView)
|
||||
}
|
||||
|
||||
data.view.frame = itemFrame
|
||||
data.textView = textView
|
||||
data.charIndexInItem = imageItem.range.location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var removeKeys: [InlineImageKey] = []
|
||||
for (key, data) in self.inlineImageViews where !validKeys.contains(key) {
|
||||
removeKeys.append(key)
|
||||
data.view.removeFromSuperview()
|
||||
}
|
||||
for key in removeKeys {
|
||||
self.inlineImageViews.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
self.updateImageReveal(animated: false)
|
||||
}
|
||||
|
||||
func updateEmojiReveal(animated: Bool) {
|
||||
for (_, data) in self.inlineStickerItemLayers {
|
||||
let revealed: Bool
|
||||
|
|
@ -368,6 +467,41 @@ public final class InstantPageV2View: UIView {
|
|||
self.updateEmojiVisibility()
|
||||
}
|
||||
|
||||
func updateImageReveal(animated: Bool) {
|
||||
for (_, data) in self.inlineImageViews {
|
||||
let revealed: Bool
|
||||
if let textView = data.textView, let count = textView.currentRevealCharacterCount {
|
||||
revealed = data.charIndexInItem < count
|
||||
} else {
|
||||
revealed = true
|
||||
}
|
||||
if data.revealed == revealed {
|
||||
continue
|
||||
}
|
||||
data.revealed = revealed
|
||||
if revealed {
|
||||
if animated {
|
||||
data.view.layer.opacity = 1.0
|
||||
let opacityAnim = CABasicAnimation(keyPath: "opacity")
|
||||
opacityAnim.fromValue = 0.0
|
||||
opacityAnim.toValue = 1.0
|
||||
opacityAnim.duration = 0.2
|
||||
data.view.layer.add(opacityAnim, forKey: "inlineImageRevealOpacity")
|
||||
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
|
||||
scaleAnim.fromValue = 0.1
|
||||
scaleAnim.toValue = 1.0
|
||||
scaleAnim.duration = 0.2
|
||||
scaleAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
data.view.layer.add(scaleAnim, forKey: "inlineImageRevealScale")
|
||||
} else {
|
||||
data.view.layer.opacity = 1.0
|
||||
}
|
||||
} else {
|
||||
data.view.layer.opacity = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateEmojiVisibility() {
|
||||
for (_, data) in self.inlineStickerItemLayers {
|
||||
let onScreen: Bool
|
||||
|
|
@ -689,6 +823,11 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|||
|
||||
private let renderContainer: UIView
|
||||
private let renderView: TextRenderView
|
||||
/// Sibling of `renderContainer`, holds inline `InstantPageV2InlineImageView`s
|
||||
/// owned by the parent `InstantPageV2View`. Sits BELOW `emojiContainerView`
|
||||
/// (so a colocated emoji renders above an image) and ABOVE `renderContainer`
|
||||
/// (so the reveal mask wipes text without clipping images).
|
||||
let imageContainerView: UIView = UIView()
|
||||
let emojiContainerView: UIView = UIView()
|
||||
let spoilerContainerView: UIView = UIView()
|
||||
private var dustNode: InvisibleInkDustNode?
|
||||
|
|
@ -726,6 +865,10 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|||
self.renderView.frame = self.bounds
|
||||
self.renderContainer.addSubview(self.renderView)
|
||||
|
||||
self.imageContainerView.frame = self.bounds
|
||||
self.imageContainerView.isUserInteractionEnabled = false
|
||||
self.addSubview(self.imageContainerView)
|
||||
|
||||
self.emojiContainerView.frame = self.bounds
|
||||
self.emojiContainerView.isUserInteractionEnabled = false
|
||||
self.addSubview(self.emojiContainerView)
|
||||
|
|
@ -745,6 +888,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|||
self.item = item
|
||||
self.renderView.item = item
|
||||
self.renderView.setNeedsDisplay()
|
||||
self.imageContainerView.frame = self.bounds
|
||||
self.emojiContainerView.frame = self.bounds
|
||||
self.spoilerContainerView.frame = self.bounds
|
||||
self.renderView.displayContentsUnderSpoilers = self.displayContentsUnderSpoilers
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import TelegramCore
|
||||
import SwiftSignalKit
|
||||
import TelegramPresentationData
|
||||
import AccountContext
|
||||
import PhotoResources
|
||||
import MediaResources
|
||||
|
||||
/// Lightweight inline image view for InstantPage V2 — wraps a `TransformImageNode`
|
||||
/// to render a single `RichText.image` cell inside a text view.
|
||||
///
|
||||
/// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in
|
||||
/// the view-factory switch). Hosted inside the parent text view's
|
||||
/// `imageContainerView` (sibling of `renderContainer`, above the reveal mask,
|
||||
/// below `emojiContainerView`), so the streaming reveal can wipe text glyphs
|
||||
/// while the image pops in independently. Non-interactive — taps pass through
|
||||
/// to the text view, so a URL-wrapping `RichText.url(text: .image(...))`
|
||||
/// continues to route taps to the URL handler.
|
||||
final class InstantPageV2InlineImageView: UIView {
|
||||
let fileId: Int64
|
||||
private let imageNode: TransformImageNode
|
||||
private let media: EngineMedia
|
||||
private let theme: InstantPageTheme
|
||||
private let fetchedDisposable = MetaDisposable()
|
||||
|
||||
init(media: EngineMedia,
|
||||
webpage: TelegramMediaWebpage?,
|
||||
frame: CGRect,
|
||||
context: AccountContext,
|
||||
userLocation: MediaResourceUserLocation,
|
||||
theme: InstantPageTheme) {
|
||||
self.media = media
|
||||
self.theme = theme
|
||||
self.fileId = media.id?.id ?? 0
|
||||
self.imageNode = TransformImageNode()
|
||||
|
||||
super.init(frame: frame)
|
||||
self.isUserInteractionEnabled = false
|
||||
self.addSubview(self.imageNode.view)
|
||||
|
||||
self.bindSignal(webpage: webpage, context: context, userLocation: userLocation)
|
||||
self.applyLayout(size: frame.size)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
deinit {
|
||||
self.fetchedDisposable.dispose()
|
||||
}
|
||||
|
||||
private func bindSignal(webpage: TelegramMediaWebpage?,
|
||||
context: AccountContext,
|
||||
userLocation: MediaResourceUserLocation) {
|
||||
// Without a webpage we can't form a `WebpageReference` for the standard
|
||||
// chat-message signals, so the image node stays at its empty colour.
|
||||
guard let webpage = webpage else { return }
|
||||
let webPageRef = WebpageReference(webpage)
|
||||
|
||||
switch self.media {
|
||||
case let .image(image):
|
||||
let imageReference = ImageMediaReference.webPage(webPage: webPageRef, media: image)
|
||||
self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox,
|
||||
userLocation: userLocation,
|
||||
photoReference: imageReference))
|
||||
// Non-interactive: always auto-fetch so the image arrives without a tap.
|
||||
self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context,
|
||||
userLocation: userLocation,
|
||||
photoReference: imageReference,
|
||||
displayAtSize: nil,
|
||||
storeToDownloadsPeerId: nil).start())
|
||||
case let .file(file):
|
||||
let fileReference = FileMediaReference.webPage(webPage: webPageRef, media: file)
|
||||
if file.mimeType.hasPrefix("image/") {
|
||||
self.fetchedDisposable.set(freeMediaFileInteractiveFetched(account: context.account,
|
||||
userLocation: userLocation,
|
||||
fileReference: fileReference).start())
|
||||
self.imageNode.setSignal(instantPageImageFile(account: context.account,
|
||||
userLocation: userLocation,
|
||||
fileReference: fileReference,
|
||||
fetched: true))
|
||||
} else {
|
||||
// Video / animated file: render a single still frame. No play overlay.
|
||||
self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox,
|
||||
userLocation: userLocation,
|
||||
videoReference: fileReference))
|
||||
}
|
||||
default:
|
||||
// RichText.image's MediaId resolves to .image or .file in practice; other
|
||||
// EngineMedia kinds (geo, webpage, story, ...) leave the image node blank.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func applyLayout(size: CGSize) {
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
self.imageNode.frame = CGRect(origin: .zero, size: size)
|
||||
|
||||
let intrinsic: CGSize
|
||||
switch self.media {
|
||||
case let .image(image):
|
||||
if let largest = largestImageRepresentation(image.representations) {
|
||||
intrinsic = largest.dimensions.cgSize
|
||||
} else {
|
||||
intrinsic = size
|
||||
}
|
||||
case let .file(file):
|
||||
intrinsic = file.dimensions?.cgSize ?? size
|
||||
default:
|
||||
intrinsic = size
|
||||
}
|
||||
|
||||
let imageSize = intrinsic.aspectFilled(size)
|
||||
let arguments = TransformImageArguments(
|
||||
corners: ImageCorners(),
|
||||
imageSize: imageSize,
|
||||
boundingSize: size,
|
||||
intrinsicInsets: UIEdgeInsets(),
|
||||
emptyColor: nil
|
||||
)
|
||||
let apply = self.imageNode.asyncLayout()(arguments)
|
||||
apply()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if self.imageNode.frame.size != self.bounds.size {
|
||||
self.applyLayout(size: self.bounds.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,11 +14,23 @@ public struct InstantPageV2Layout {
|
|||
public let items: [InstantPageV2LaidOutItem]
|
||||
/// Snapshot of the `index` values of every `.details` item present in `items`, captured at layout time.
|
||||
public let detailsIndices: [Int]
|
||||
/// Media dictionary inherited from the page's `LayoutContext.media`. Used by
|
||||
/// `InstantPageV2View.updateInlineImages()` to resolve each text view's
|
||||
/// `line.imageItems` MediaIds at update time. Nested layouts (details body,
|
||||
/// table cells, table title) carry the parent's same map.
|
||||
public let media: [EngineMedia.Id: EngineMedia]
|
||||
/// Webpage carried for the same reason — `updateInlineImages()` needs it to
|
||||
/// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May
|
||||
/// be nil for non-webpage-anchored layouts; in that case the lookup proceeds
|
||||
/// but no fetch signal can be bound (image view simply isn't created).
|
||||
public let webpage: TelegramMediaWebpage?
|
||||
|
||||
public init(contentSize: CGSize, items: [InstantPageV2LaidOutItem], detailsIndices: [Int]) {
|
||||
public init(contentSize: CGSize, items: [InstantPageV2LaidOutItem], detailsIndices: [Int], media: [EngineMedia.Id: EngineMedia] = [:], webpage: TelegramMediaWebpage? = nil) {
|
||||
self.contentSize = contentSize
|
||||
self.items = items
|
||||
self.detailsIndices = detailsIndices
|
||||
self.media = media
|
||||
self.webpage = webpage
|
||||
}
|
||||
|
||||
/// Returns every `InstantPageMedia` produced by this layout (or its nested sub-layouts)
|
||||
|
|
@ -516,7 +528,7 @@ private func layoutBlockSequence(
|
|||
contentSize.width = min(maxX, boundingWidth)
|
||||
}
|
||||
|
||||
return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices)
|
||||
return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices, media: context.media, webpage: context.webpage)
|
||||
}
|
||||
|
||||
private func layoutBlock(
|
||||
|
|
@ -920,8 +932,6 @@ private func layoutDetails(
|
|||
attributedStringForRichText(title, styleStack: titleStyleStack),
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0 - 32.0, // reserve right edge for chevron
|
||||
offset: CGPoint(x: horizontalInset, y: 12.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1023,8 +1033,6 @@ private func layoutTable(
|
|||
attrStr,
|
||||
boundingWidth: cellWidthLimit - totalCellPadding,
|
||||
offset: CGPoint(),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
minimizeWidth: true,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
|
|
@ -1035,8 +1043,6 @@ private func layoutTable(
|
|||
attrStr,
|
||||
boundingWidth: cellWidthLimit - totalCellPadding,
|
||||
offset: CGPoint(),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
).0 {
|
||||
|
|
@ -1304,7 +1310,7 @@ private func layoutTable(
|
|||
// Translate all items in the sub-layout by the inset.
|
||||
let delta = CGPoint(x: horizOffset, y: vertOffset)
|
||||
let translatedItems = sl.items.map { $0.offsetBy(delta) }
|
||||
sl = InstantPageV2Layout(contentSize: sl.contentSize, items: translatedItems, detailsIndices: sl.detailsIndices)
|
||||
sl = InstantPageV2Layout(contentSize: sl.contentSize, items: translatedItems, detailsIndices: sl.detailsIndices, media: sl.media, webpage: sl.webpage)
|
||||
subLayout = sl
|
||||
}
|
||||
|
||||
|
|
@ -1508,8 +1514,6 @@ private func layoutCaptionAndCredit(
|
|||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: y),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1537,8 +1541,6 @@ private func layoutCaptionAndCredit(
|
|||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
alignment: rtl ? .right : .natural,
|
||||
offset: CGPoint(x: horizontalInset, y: y),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1679,8 +1681,6 @@ private func layoutSimpleText(
|
|||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1701,8 +1701,6 @@ private func layoutHeading(
|
|||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1726,8 +1724,6 @@ private func layoutParagraph(
|
|||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1798,8 +1794,6 @@ private func layoutAuthorDate(
|
|||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
alignment: alignment,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1862,8 +1856,6 @@ private func layoutCodeBlock(
|
|||
attributedString,
|
||||
boundingWidth: innerWidth,
|
||||
offset: CGPoint(x: 0.0, y: 0.0),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
opaqueBackground: true,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
|
|
@ -1946,8 +1938,6 @@ private func layoutBlockQuote(
|
|||
boundingWidth: textBoundingWidth,
|
||||
alignment: textAlignment,
|
||||
offset: CGPoint(x: textX, y: contentHeight),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -1969,8 +1959,6 @@ private func layoutBlockQuote(
|
|||
boundingWidth: textBoundingWidth,
|
||||
alignment: textAlignment,
|
||||
offset: CGPoint(x: textX, y: contentHeight),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -2126,8 +2114,6 @@ private func layoutList(
|
|||
attrStr,
|
||||
boundingWidth: textWidth,
|
||||
offset: CGPoint(x: textX, y: contentHeight),
|
||||
media: context.media,
|
||||
webpage: context.webpage,
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
|
|
@ -2411,8 +2397,10 @@ private func attributedStringForPreformattedText(_ text: RichText, language: Str
|
|||
// MARK: - V2 text-item layout (ported from V1 InstantPageTextItem.swift layoutTextItemWithString)
|
||||
//
|
||||
// V0 difference from V1:
|
||||
// * Inline image runs produce `.mediaPlaceholder(kind: .image, frame:, cornerRadius: 0)` items
|
||||
// instead of `InstantPageImageItem`.
|
||||
// * Inline image runs are NOT emitted as items here. They are discovered at view-update time
|
||||
// by `InstantPageV2View.updateInlineImages()`, which walks each text view's `line.imageItems`
|
||||
// and creates `InstantPageV2InlineImageView`s attached to the text view's `imageContainerView`
|
||||
// (the pop-in animation mirrors the inline custom-emoji ownership model).
|
||||
// * Inline formula runs produce `.formula(InstantPageV2FormulaItem(...))` items carrying the
|
||||
// rendered math image (see `InstantPageV2FormulaView`); the line's `formulaItems` field
|
||||
// already provides the attachment + frame.
|
||||
|
|
@ -2485,8 +2473,6 @@ func layoutTextItem(
|
|||
horizontalInset: CGFloat = 0.0,
|
||||
alignment: NSTextAlignment = .natural,
|
||||
offset: CGPoint,
|
||||
media: [EngineMedia.Id: EngineMedia] = [:],
|
||||
webpage: TelegramMediaWebpage? = nil,
|
||||
minimizeWidth: Bool = false,
|
||||
fitToWidth: Bool = false,
|
||||
maxNumberOfLines: Int = 0,
|
||||
|
|
@ -2814,6 +2800,17 @@ func layoutTextItem(
|
|||
rects[localIndex] = CGRect(x: x, y: 0.0, width: emoji.size, height: emoji.size)
|
||||
}
|
||||
}
|
||||
for image in pendingImages {
|
||||
let localIndex = image.range.location - lineRange.location
|
||||
if localIndex >= 0 && localIndex < rects.count {
|
||||
let x = CTLineGetOffsetForStringIndex(line, image.range.location, nil)
|
||||
// Image cell sits bottom-on-baseline (frame loop: y = baselineY - image.size.height).
|
||||
// Baseline-relative cell: y = 0, height = image.size.height. The full width feeds
|
||||
// the reveal cost map so the streaming cursor is charged the image's width when
|
||||
// crossing it — same as an emoji cell.
|
||||
rects[localIndex] = CGRect(x: x, y: 0.0, width: image.size.width, height: image.size.height)
|
||||
}
|
||||
}
|
||||
lineCharacterRects = rects
|
||||
} else {
|
||||
lineCharacterRects = nil
|
||||
|
|
@ -2870,21 +2867,12 @@ func layoutTextItem(
|
|||
let effectiveOffset = offset
|
||||
for line in textItem.lines {
|
||||
let lineFrame = v2FrameForLine(line, boundingWidth: boundingWidth, alignment: alignment)
|
||||
if let _ = webpage {
|
||||
for imageItem in line.imageItems {
|
||||
if media[imageItem.id] != nil {
|
||||
let placeholderFrame = imageItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y)
|
||||
let placeholder = InstantPageV2MediaPlaceholderItem(
|
||||
frame: placeholderFrame,
|
||||
kind: .image,
|
||||
cornerRadius: 0.0
|
||||
)
|
||||
additionalItems.append(.mediaPlaceholder(placeholder))
|
||||
if placeholderFrame.minY < topInset { topInset = placeholderFrame.minY }
|
||||
if placeholderFrame.maxY > height { bottomInset = max(bottomInset, placeholderFrame.maxY - height) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inline images (RichText.image) are NOT emitted as top-level items here. They are
|
||||
// discovered at view-update time by InstantPageV2View.updateInlineImages(), which
|
||||
// walks each text view's `line.imageItems` and creates an InstantPageV2InlineImageView
|
||||
// attached to the text view's imageContainerView. The pop-in animation reuses the
|
||||
// emoji-style ownership model. See the inline-image design doc:
|
||||
// docs/superpowers/specs/2026-05-28-instantpage-v2-inline-image-design.md.
|
||||
for formulaItem in line.formulaItems {
|
||||
let formulaFrame = formulaItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y)
|
||||
let item = InstantPageV2FormulaItem(
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ public extension InstantPageV2View {
|
|||
clearRevealOn(view: view, animated: animated)
|
||||
}
|
||||
self.updateEmojiReveal(animated: animated)
|
||||
self.updateImageReveal(animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -370,6 +371,7 @@ public extension InstantPageV2View {
|
|||
}
|
||||
}
|
||||
self.updateEmojiReveal(animated: animated)
|
||||
self.updateImageReveal(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue