diff --git a/docs/instantpage-richtext.md b/docs/instantpage-richtext.md index 24072ce44f..2f91a2962d 100644 --- a/docs/instantpage-richtext.md +++ b/docs/instantpage-richtext.md @@ -164,6 +164,7 @@ Specs: [`2026-06-02-instantpage-v2-audio-design.md`](docs/superpowers/specs/2026 - **`ChatTextInputTextCustomEmojiAttribute` is reused end-to-end** (display layer ⇄ layout model). The attribute is written to the placeholder in `attributedStringForRichText` and read back by the V2 line-breaker under the SAME key (`ChatTextInputAttributes.customEmoji`); `InlineStickerItemLayer.init` consumes it directly and resolves the file lazily from `fileId`. - **Emoji participates in the streaming reveal.** Its placeholder char's `characterRect` is overwritten to a full cell (width = `itemSize`), so the width-based cost map charges it like other content. `updateEmojiReveal` pops the layer in (alpha 0→1 + scale) when `charIndexInItem < currentRevealCharacterCount`; unrevealed → opacity 0. - **Inline emoji/images are CENTERED on the font line box, NOT baseline-aligned, and do NOT inflate the line.** The line-breaker keeps `lineAscent = fontLineHeight` (only formulas grow it) and places each attachment at `baselineY − fontLineHeight/2 − size/2`, so it bleeds symmetrically about the line box instead of doubling the line height and shoving the text baseline down (the prior `lineAscent = emoji.size` behavior was a regression from V1 `layoutTextItemWithString`, which centers via `(fontLineHeight − imageHeight)/2`). Custom emoji are sized to ≈ the line box (`size = font.ascender − font.descender + 4·pointSize/17`) so they fit the true-font-height item box (see "InstantPage V2 text item height") with minimal bleed. Mirrors the chat `InteractiveTextComponent`. The cell's `characterRect` is centered the same way (`y = fontLineHeight/2 − size/2`) so the reveal mask (`renderer: y = minY + lineAscent − rect.maxY`) tracks it; a tall attachment grows `extraDescent` so the next line isn't overlapped. Three things must stay in lockstep: the display frame, the `characterRect`, and `extraDescent`. +- **Inline-attachment x must be the LEADING edge, computed RTL-safely via `v2LeadingOffsetForRange` (`InstantPageV2Layout.swift`).** An attachment's left edge is `min(CTLineGetOffsetForStringIndex(start), CTLineGetOffsetForStringIndex(end))` — NOT the bare start-index offset. `CTLineGetOffsetForStringIndex` at the start index returns the glyph's LEFT edge in LTR but its RIGHT edge in RTL (string index increases leftward), so the old single-offset form (`…, range.location, nil`) shoved emoji/images/formulas ~one advance (≈ the attachment width) too far right on RTL lines — e.g. an emoji in an Arabic thinking-block line, while the CoreText-drawn text stayed correct. The helper mirrors `Display.TextNode`'s `addEmbeddedItem` (incl. directional-boundary secondary-offset handling) and the strikethrough/underline/marked/spoiler decorations in this same file, which already used the `min`/`abs` form. For pure-LTR lines it returns exactly the start-index offset, so LTR is byte-identical. Applies to all 5 attachment sites: the emoji/image/formula display frames AND the emoji/image `characterRect` (reveal mask). The widths stay the fixed `size`/`rendered.size` values (the run-delegate advance), only the x is corrected. - **Layers sit ABOVE the reveal mask.** They attach to `InstantPageV2TextView.emojiContainerView` (a sibling above `renderContainer`), NOT inside it — so the reveal mask wipes glyphs while emoji pop in independently. Adding a CTRunDelegate-glyph to the mask would clip-wipe them instead. - **Layers are owned by `InstantPageV2View`, not the text view.** Keyed by `InlineStickerItemLayer.Key(id: fileId, index: occurrence)`. The pageView is now REUSED across `stableVersion` bumps (see streaming section), so the inline-emoji dict PERSISTS across chunks; `updateInlineEmoji` prunes stale keys (emoji whose blocks have been removed) and creates/repositions layers for new or unchanged emoji each update pass. - **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set. `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`. diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 66b4ae7331..2f20740493 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -2966,6 +2966,33 @@ func v2FrameForLine(_ line: InstantPageTextLine, boundingWidth: CGFloat, alignme return lineFrame } +// Returns the leading-edge x offset (line-origin-relative) for an inline-attachment's string +// `range`, correct for both LTR and RTL runs. `CTLineGetOffsetForStringIndex` at the start index +// gives the glyph's LEFT edge in LTR text, but its RIGHT edge in RTL text (increasing string index +// moves leftward) — so using the start-index offset alone as the left edge shoves an RTL attachment +// ~one advance too far right. Taking the min of the start- and end-index offsets yields the true +// leading (left) edge in both directions. Mirrors `Display.TextNode`'s `addEmbeddedItem`, including +// the directional-boundary secondary-offset handling. For a pure-LTR line this returns exactly the +// start-index offset (primary == secondary, and start-offset < end-offset), so LTR layout is +// byte-identical to the previous single-offset behavior. +private func v2LeadingOffsetForRange(_ line: CTLine, range: NSRange) -> CGFloat { + var secondaryStartOffset: CGFloat = 0.0 + let rawStartOffset = CTLineGetOffsetForStringIndex(line, range.location, &secondaryStartOffset) + var startOffset = rawStartOffset + if !rawStartOffset.isEqual(to: secondaryStartOffset) { + startOffset = secondaryStartOffset + } + + var secondaryEndOffset: CGFloat = 0.0 + let rawEndOffset = CTLineGetOffsetForStringIndex(line, range.location + range.length, &secondaryEndOffset) + var endOffset = rawEndOffset + if !rawEndOffset.isEqual(to: secondaryEndOffset) { + endOffset = secondaryEndOffset + } + + return min(startOffset, endOffset) +} + private func v2LocalAttachmentBoundsForRange(_ range: NSRange, imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun]) -> CGRect? { var result: CGRect? @@ -3144,14 +3171,14 @@ func layoutTextItem( string.enumerateAttributes(in: runRange, options: []) { attributes, range, _ in if let id = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaIdAttribute)] as? Int64, let dimensions = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaDimensionsAttribute)] as? PixelDimensions { let imageSize = dimensions.cgSize.fitted(CGSize(width: boundingWidth, height: boundingWidth)) - let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil) + let xOffset = v2LeadingOffsetForRange(line, range: range) pendingImages.append(PendingV2ImageAttachment(xOffset: xOffset, range: range, id: id, size: imageSize)) } else if let attachment = attributes[NSAttributedString.Key(rawValue: InstantPageFormulaAttribute)] as? InstantPageMathAttachment { - let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil) + let xOffset = v2LeadingOffsetForRange(line, range: range) let baselineOffset = (attributes[NSAttributedString.Key.baselineOffset] as? CGFloat) ?? 0.0 pendingFormulas.append(PendingV2FormulaAttachment(xOffset: xOffset, range: range, attachment: attachment, baselineOffset: baselineOffset)) } else if let emoji = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute { - let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil) + let xOffset = v2LeadingOffsetForRange(line, range: range) let font = (attributes[NSAttributedString.Key.font] as? UIFont) ?? UIFont.systemFont(ofSize: 17.0) // Size the inline emoji to the font's line height (A + D = the true // line-box height) plus a 4pt bump at the 17pt body font (scaled @@ -3363,7 +3390,7 @@ func layoutTextItem( for emoji in pendingEmoji { let localIndex = emoji.range.location - lineRange.location if localIndex >= 0 && localIndex < rects.count { - let x = CTLineGetOffsetForStringIndex(line, emoji.range.location, nil) + let x = v2LeadingOffsetForRange(line, range: emoji.range) // characterRects are baseline-relative (positive-up). The emoji cell is now // centered on the font line box (see frame loop), so in baseline-relative // coords it spans [fontLineHeight/2 − size/2, fontLineHeight/2 + size/2]. @@ -3375,7 +3402,7 @@ func layoutTextItem( 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) + let x = v2LeadingOffsetForRange(line, range: image.range) // Image cell is centered on the font line box (see frame loop). Baseline-relative // cell spans [fontLineHeight/2 − height/2, fontLineHeight/2 + height/2]; the full // width feeds the reveal cost map so the streaming cursor is charged the image's