Fix inline emoji/image line-height inflation in InstantPage V2
The V2 line-breaker used `lineAscent` as both the line height and the baseline offset, then inflated it to each inline emoji/image's full visual size and bottom-aligned the attachment on that inflated baseline. A 24pt emoji on a ~17pt line therefore doubled the line height and shoved the text baseline (and all text on the line) down. Stop inflating the line for emoji/images (only formulas, which carry their own metrics, still grow it) and center each attachment on the font line box at `baselineY - fontLineHeight/2 - size/2`, matching V1 `layoutTextItemWithString` and the chat `InteractiveTextComponent`. The attachment now bleeds symmetrically instead of moving the baseline. `extraDescent` absorbs tall-attachment bottom overflow so the next line is not overlapped, and the streaming-reveal `characterRect` is centered in lockstep so the reveal mask tracks the cell (reveal cost stays width-only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a3492c0cb0
commit
9205fb2303
2 changed files with 37 additions and 23 deletions
|
|
@ -2838,13 +2838,15 @@ func layoutTextItem(
|
|||
}
|
||||
}
|
||||
|
||||
// Inline emoji and images do NOT inflate the line: they are centered on the font
|
||||
// line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString`
|
||||
// and the chat `InteractiveTextComponent`). Their run delegates already report the
|
||||
// font's own ascent/descent, so CoreText lays the line out at the normal height — the
|
||||
// old `lineAscent = emoji.size` inflation both doubled the line height and (because the
|
||||
// baseline sits at the bottom of the box) shoved the text baseline down. Only formulas,
|
||||
// which carry their own typographic metrics, are allowed to grow the line.
|
||||
var lineAscent: CGFloat = fontLineHeight
|
||||
var lineDescent: CGFloat = fontDescentBelowBaseline
|
||||
for image in pendingImages {
|
||||
if image.size.height > lineAscent {
|
||||
lineAscent = image.size.height
|
||||
}
|
||||
}
|
||||
for formula in pendingFormulas {
|
||||
let formulaAscent = formula.attachment.rendered.size.height - formula.attachment.rendered.descent
|
||||
if formulaAscent > lineAscent {
|
||||
|
|
@ -2854,17 +2856,15 @@ func layoutTextItem(
|
|||
lineDescent = formula.attachment.rendered.descent
|
||||
}
|
||||
}
|
||||
for emoji in pendingEmoji {
|
||||
if emoji.size > lineAscent {
|
||||
lineAscent = emoji.size
|
||||
}
|
||||
}
|
||||
let baselineY = workingLineOrigin.y + lineAscent
|
||||
|
||||
for image in pendingImages {
|
||||
// Center on the font line box (baseline − fontLineHeight/2), matching V1's
|
||||
// `(fontLineHeight - imageHeight) / 2` offset, instead of bottom-aligning on the
|
||||
// baseline. Keeps the text baseline put and lets the image bleed symmetrically.
|
||||
let imageFrame = CGRect(
|
||||
x: workingLineOrigin.x + image.xOffset,
|
||||
y: baselineY - image.size.height,
|
||||
y: floorToScreenPixels(baselineY - fontLineHeight / 2.0 - image.size.height / 2.0),
|
||||
width: image.size.width,
|
||||
height: image.size.height
|
||||
)
|
||||
|
|
@ -2882,9 +2882,12 @@ func layoutTextItem(
|
|||
lineFormulaItems.append(InstantPageTextFormulaRun(frame: formulaFrame, range: formula.range, attachment: attachment))
|
||||
}
|
||||
for emoji in pendingEmoji {
|
||||
// Center on the font line box (baseline − fontLineHeight/2) so a 24pt emoji on a
|
||||
// ~17pt line bleeds symmetrically rather than forcing the line taller and pushing
|
||||
// the text baseline down. Matches the chat `InteractiveTextComponent` placement.
|
||||
let emojiFrame = CGRect(
|
||||
x: workingLineOrigin.x + emoji.xOffset,
|
||||
y: baselineY - emoji.size,
|
||||
y: floorToScreenPixels(baselineY - fontLineHeight / 2.0 - emoji.size / 2.0),
|
||||
width: emoji.size,
|
||||
height: emoji.size
|
||||
)
|
||||
|
|
@ -2892,6 +2895,15 @@ func layoutTextItem(
|
|||
}
|
||||
|
||||
extraDescent = max(0.0, lineDescent - baselineToNextTopSlack)
|
||||
// A centered attachment taller than the line bleeds below the baseline; grow the
|
||||
// descent so the following line isn't overlapped (mirrors V1's extraDescent handling).
|
||||
// Emoji at the default 24/17 ratio stay within the line slack and contribute nothing.
|
||||
for imageItem in lineImageItems {
|
||||
extraDescent = max(extraDescent, imageItem.frame.maxY - (baselineY + baselineToNextTopSlack))
|
||||
}
|
||||
for emojiItem in lineEmojiItems {
|
||||
extraDescent = max(extraDescent, emojiItem.frame.maxY - (baselineY + baselineToNextTopSlack))
|
||||
}
|
||||
|
||||
if !minimizeWidth && !hadIndexOffset && lineCharacterCount > 1 && lineWidth > currentMaxWidth + 5.0 {
|
||||
if let imageItem = lineImageItems.last {
|
||||
|
|
@ -3025,22 +3037,23 @@ func layoutTextItem(
|
|||
let localIndex = emoji.range.location - lineRange.location
|
||||
if localIndex >= 0 && localIndex < rects.count {
|
||||
let x = CTLineGetOffsetForStringIndex(line, emoji.range.location, nil)
|
||||
// characterRects are baseline-relative (positive-up). The emoji cell sits
|
||||
// bottom-on-baseline (see frame loop: y = baselineY - emoji.size), so its
|
||||
// baseline-relative bottom is 0 and maxY = emoji.size — the width feeds the
|
||||
// reveal cost map; maxY feeds the reveal-mask y conversion in the renderer.
|
||||
rects[localIndex] = CGRect(x: x, y: 0.0, width: emoji.size, height: emoji.size)
|
||||
// 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].
|
||||
// Width feeds the reveal cost map; maxY feeds the reveal-mask y conversion in
|
||||
// the renderer (lineAscent − maxY), keeping the mask tracking the centered cell.
|
||||
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - emoji.size / 2.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)
|
||||
// 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
|
||||
// width when crossing it — same as an emoji cell.
|
||||
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height)
|
||||
}
|
||||
}
|
||||
lineCharacterRects = rects
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue