mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
InstantPage V2: formula display
Design, plan, and implementation. Adds .formula item case + InstantPageV2FormulaView, bumps InstantPageMathAttachment fields to public, routes block and inline formulas through .formula items, aligns the scroll view with V1 (alwaysBounceHorizontal = false), and reports narrow block formula natural width so the fitToWidth bubble shrinks.
This commit is contained in:
parent
6a0c8d9e07
commit
0a3030deed
3 changed files with 161 additions and 26 deletions
|
|
@ -2,27 +2,27 @@ import Foundation
|
|||
import UIKit
|
||||
import SwiftMath
|
||||
|
||||
enum InstantPageMathMode {
|
||||
public enum InstantPageMathMode {
|
||||
case inline
|
||||
case block
|
||||
}
|
||||
|
||||
struct InstantPageMathRenderResult {
|
||||
let image: UIImage
|
||||
let size: CGSize
|
||||
let width: CGFloat
|
||||
let ascent: CGFloat
|
||||
let descent: CGFloat
|
||||
public struct InstantPageMathRenderResult {
|
||||
public let image: UIImage
|
||||
public let size: CGSize
|
||||
public let width: CGFloat
|
||||
public let ascent: CGFloat
|
||||
public let descent: CGFloat
|
||||
}
|
||||
|
||||
final class InstantPageMathAttachment: NSObject {
|
||||
let latex: String
|
||||
let fontSize: CGFloat
|
||||
let textColor: UIColor
|
||||
let mode: InstantPageMathMode
|
||||
let rendered: InstantPageMathRenderResult
|
||||
public final class InstantPageMathAttachment: NSObject {
|
||||
public let latex: String
|
||||
public let fontSize: CGFloat
|
||||
public let textColor: UIColor
|
||||
public let mode: InstantPageMathMode
|
||||
public let rendered: InstantPageMathRenderResult
|
||||
|
||||
init(latex: String, fontSize: CGFloat, textColor: UIColor, mode: InstantPageMathMode, rendered: InstantPageMathRenderResult) {
|
||||
public init(latex: String, fontSize: CGFloat, textColor: UIColor, mode: InstantPageMathMode, rendered: InstantPageMathRenderResult) {
|
||||
self.latex = latex
|
||||
self.fontSize = fontSize
|
||||
self.textColor = textColor
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ public final class InstantPageV2View: UIView {
|
|||
return view
|
||||
case let .table(table):
|
||||
return InstantPageV2TableView(item: table, theme: theme)
|
||||
case let .formula(formula):
|
||||
return InstantPageV2FormulaView(item: formula)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -723,3 +725,57 @@ private func collectSelectableTextItems(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let 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.clipsToBounds = true
|
||||
|
||||
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 {
|
||||
let scroll = UIScrollView(frame: self.bounds)
|
||||
scroll.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
scroll.contentSize = item.scrollContentSize
|
||||
scroll.showsHorizontalScrollIndicator = false
|
||||
scroll.showsVerticalScrollIndicator = false
|
||||
scroll.alwaysBounceHorizontal = false
|
||||
scroll.alwaysBounceVertical = false
|
||||
scroll.contentInsetAdjustmentBehavior = .never
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public enum InstantPageV2LaidOutItem {
|
|||
case details(InstantPageV2DetailsItem)
|
||||
case table(InstantPageV2TableItem)
|
||||
case anchor(InstantPageV2AnchorItem)
|
||||
case formula(InstantPageV2FormulaItem)
|
||||
|
||||
public var frame: CGRect {
|
||||
switch self {
|
||||
|
|
@ -46,6 +47,7 @@ public enum InstantPageV2LaidOutItem {
|
|||
case let .details(item): return item.frame
|
||||
case let .table(item): return item.frame
|
||||
case let .anchor(item): return item.frame
|
||||
case let .formula(item): return item.frame
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +66,7 @@ public enum InstantPageV2LaidOutItem {
|
|||
case var .details(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .details(item)
|
||||
case var .table(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .table(item)
|
||||
case var .anchor(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .anchor(item)
|
||||
case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +118,14 @@ public struct InstantPageV2ShapeItem {
|
|||
public let color: UIColor
|
||||
}
|
||||
|
||||
public struct InstantPageV2FormulaItem {
|
||||
public var frame: CGRect // outer frame in parent coords
|
||||
public let attachment: InstantPageMathAttachment // rendered image + dimensions (theme-baked)
|
||||
public let isScrollable: Bool // true only for block formulas wider than bounds
|
||||
public let imageFrame: CGRect // image rect in this item's local coords; size must equal attachment.rendered.size for pixel-perfect rendering
|
||||
public let scrollContentSize: CGSize // == frame.size unless isScrollable
|
||||
}
|
||||
|
||||
public enum InstantPageV2MediaPlaceholderKind {
|
||||
case image
|
||||
case video
|
||||
|
|
@ -126,7 +137,6 @@ public enum InstantPageV2MediaPlaceholderKind {
|
|||
case channelBanner
|
||||
case map
|
||||
case relatedArticles
|
||||
case formula
|
||||
}
|
||||
|
||||
public struct InstantPageV2MediaPlaceholderItem {
|
||||
|
|
@ -495,12 +505,10 @@ private func layoutBlock(
|
|||
horizontalInset: horizontalInset, context: &context)
|
||||
|
||||
case let .formula(latex):
|
||||
let _ = latex
|
||||
return layoutMediaWithCaption(kind: .formula,
|
||||
naturalSize: CGSize(width: boundingWidth, height: 40.0),
|
||||
caption: InstantPageCaption(text: .empty, credit: .empty),
|
||||
isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
return layoutFormulaBlock(latex: latex,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
context: &context)
|
||||
|
||||
case let .details(title, blocks, expanded):
|
||||
return layoutDetails(title: title, blocks: blocks, defaultExpanded: expanded,
|
||||
|
|
@ -518,6 +526,73 @@ private func layoutBlock(
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Formula block layout
|
||||
|
||||
/// Lays out a top-level `InstantPageBlock.formula(latex:)`. The latex is rendered synchronously
|
||||
/// through `instantPageMathAttachment(...)` (SwiftMath → `MTMathRenderer`) using the current
|
||||
/// paragraph theme's color and font size; the resulting pre-rendered `UIImage` is wrapped in
|
||||
/// an `InstantPageV2FormulaItem`. Wide formulas set `isScrollable = true`; on render failure
|
||||
/// the raw latex source is laid out as a regular paragraph (matches V1 fallback).
|
||||
private func layoutFormulaBlock(
|
||||
latex: String,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
// Style stack matches V1's per-block formula (paragraph category, not header).
|
||||
let styleStack = InstantPageTextStyleStack()
|
||||
setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false)
|
||||
let attributes = styleStack.textAttributes()
|
||||
let textColor = (attributes[.foregroundColor] as? UIColor)
|
||||
?? context.theme.textCategories.paragraph.color
|
||||
let fontSize = (attributes[.font] as? UIFont)?.pointSize
|
||||
?? context.theme.textCategories.paragraph.font.size
|
||||
|
||||
guard let attachment = instantPageMathAttachment(latex: latex,
|
||||
fontSize: fontSize,
|
||||
textColor: textColor,
|
||||
mode: .block) else {
|
||||
// Render failure: fall back to the raw latex source as a regular paragraph.
|
||||
return layoutParagraph(.plain(latex),
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
previousItems: [],
|
||||
context: &context)
|
||||
}
|
||||
|
||||
let availableWidth = boundingWidth - horizontalInset * 2.0
|
||||
let renderedSize = attachment.rendered.size
|
||||
|
||||
if renderedSize.width > availableWidth {
|
||||
// Wide formula: scroll view fills the bubble's available width so the image can pan inside.
|
||||
let frame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: renderedSize.height)
|
||||
let item = InstantPageV2FormulaItem(
|
||||
frame: frame,
|
||||
attachment: attachment,
|
||||
isScrollable: true,
|
||||
imageFrame: CGRect(x: horizontalInset, y: 0.0,
|
||||
width: renderedSize.width, height: renderedSize.height),
|
||||
scrollContentSize: CGSize(width: renderedSize.width + horizontalInset * 2.0,
|
||||
height: renderedSize.height)
|
||||
)
|
||||
return [.formula(item)]
|
||||
} else {
|
||||
// Narrow formula: report the image's natural extent (left-inset + width) so the
|
||||
// bubble's `fitToWidth` contentSize shrinks instead of stretching to `boundingWidth`.
|
||||
// When the formula is the widest item, the bubble centers itself in the chat row.
|
||||
let frame = CGRect(x: horizontalInset, y: 0.0,
|
||||
width: renderedSize.width, height: renderedSize.height)
|
||||
let item = InstantPageV2FormulaItem(
|
||||
frame: frame,
|
||||
attachment: attachment,
|
||||
isScrollable: false,
|
||||
imageFrame: CGRect(origin: .zero, size: renderedSize),
|
||||
scrollContentSize: renderedSize
|
||||
)
|
||||
return [.formula(item)]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Details layout
|
||||
|
||||
private func layoutDetails(
|
||||
|
|
@ -1978,7 +2053,9 @@ private func attributedStringForPreformattedText(_ text: RichText, language: Str
|
|||
// V0 difference from V1:
|
||||
// * Inline image runs produce `.mediaPlaceholder(kind: .image, frame:, cornerRadius: 0)` items
|
||||
// instead of `InstantPageImageItem`.
|
||||
// * Inline formula runs produce `.mediaPlaceholder(kind: .formula, frame:, cornerRadius: 0)` items.
|
||||
// * Inline formula runs produce `.formula(InstantPageV2FormulaItem(...))` items carrying the
|
||||
// rendered math image (see `InstantPageV2FormulaView`); the line's `formulaItems` field
|
||||
// already provides the attachment + frame.
|
||||
// * No `InstantPageScrollableTextItem` wrapping: even if `requiresScroll` would be true in V1,
|
||||
// V2 takes the non-scroll path (text item kept flat; long preformatted lines simply clip
|
||||
// outside the bubble width). Deferred to a future iteration.
|
||||
|
|
@ -2341,12 +2418,14 @@ func layoutTextItem(
|
|||
}
|
||||
for formulaItem in line.formulaItems {
|
||||
let formulaFrame = formulaItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y)
|
||||
let placeholder = InstantPageV2MediaPlaceholderItem(
|
||||
let item = InstantPageV2FormulaItem(
|
||||
frame: formulaFrame,
|
||||
kind: .formula,
|
||||
cornerRadius: 0.0
|
||||
attachment: formulaItem.attachment,
|
||||
isScrollable: false,
|
||||
imageFrame: CGRect(origin: .zero, size: formulaFrame.size),
|
||||
scrollContentSize: formulaFrame.size
|
||||
)
|
||||
additionalItems.append(.mediaPlaceholder(placeholder))
|
||||
additionalItems.append(.formula(item))
|
||||
if formulaFrame.minY < topInset { topInset = formulaFrame.minY }
|
||||
if formulaFrame.maxY > height { bottomInset = max(bottomInset, formulaFrame.maxY - height) }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue