Emoji: selection outline does not encompass the glyph after resize #123
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#123
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
After resizing an emoji larger, the selection outline (transformer bounding box) doesn't match the visible glyph — the emoji extends beyond the outline, especially vertically.
Steps to reproduce
Expected
The outline visually contains the emoji glyph.
Actual
The emoji spills above (and to a smaller extent below) the outline. See attached screenshot.
Root cause
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js::applyTransformupdates the hit / outline rect fromemojiText.height()after changingfontSize. Konva.Text returnsheight = fontSize * lineHeight, and the emoji text node is created withoutlineHeight(default = 1), so the layout box is exactlyfontSizetall.But system emoji glyphs (Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji) render with a small ascender / descender extent that spills above and below that layout box. At
fontSize: 48the overflow is a few pixels and not visible; at large sizes (200+) it becomes obvious.Fix
Set
lineHeight: 1.2on the emoji text node increateEmojiso the layout box is ~20% taller than the nominalfontSize.emojiText.height()then returnsfontSize * 1.2consistently, both at create time and after every resize, so the bg / outline automatically encompasses the glyph at any size.No change needed in
applyTransform— it already readsemojiText.height(). Existing persisted emojis re-load with the new lineHeight applied; their saved width is still used to compute the right fontSize.Implementation Spec for Issue #123
Objective
Make the emoji's selection / hit rectangle visually encompass the rendered glyph at any size, so the transformer outline doesn't get visibly clipped after a resize.
Files to Modify
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js— setlineHeight: 1.2on the emoji text node increateEmojisoKonva.Text.height()reportsfontSize * 1.2instead offontSize. The bg / outline derived from that height now contains the rendering overflow.Implementation Plan
Step 1: lineHeight on emoji text node
File:
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.jsIn
createEmoji, change:to add
lineHeight: 1.2:applyTransformalready readsemojiText.height()to size the bg, so it picks up the new layout height automatically with no other changes needed.Dependencies: none.
Acceptance Criteria
fontSize: 48still selects cleanly.cargo fmt,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace --libclean.Notes
fontSizefrom the saved bg width (opts.width / nat), so the glyph keeps its visual width across the change. The new layout height is~20%taller than the saved height, but the bg followstextNode.height()at the new fontSize, so the outline matches the new layout — no migration of saved data needed.Implementation Summary
One-line addition in
createEmoji:lineHeight: 1.2on the emoji text node.crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.jsThe emoji text node now uses
lineHeight: 1.2soKonva.Text.height()returnsfontSize * 1.2instead offontSize. The bg / hit / transformer outline derived from that height now contains the slight ascender / descender overflow that emoji renderers (Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji) produce above and below the default fontSize box.applyTransformalready readsemojiText.height()to size the bg, so it picks up the new layout height automatically with no other changes needed.Files Changed
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js—+6 / 0Test Results
cargo fmt --all -- --check— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo test --workspace --lib— 0 failednode --check objects.js— cleanManual smoke
Notes
opts.width / nat), so the visual size stays the same. The new layout height is~20%taller, but the bg followstextNode.height()at the new fontSize — outline matches the new layout. No data migration needed.Iteration: measure the actual glyph bounds
The first attempt (
lineHeight: 1.2) over-padded emoji like the red square (which sits with intrinsic padding inside its layout box) while only just fitting emoji like the heart (which spills above the layout box). Per-glyph variance in emoji rendering meant a fixed factor couldn't fit both.Reverted
lineHeightto default and switched to measurement-based bounds.Approach
A new
measureEmojiBounds(char, fontSize)helper uses an offscreenCanvas.measureText()and readsactualBoundingBoxLeft / Right / Ascent / Descent(supported in every modern browser as of 2019). Those return the rasterized glyph extent, not the layout advance. Cached per(char, fontSize)so repeated transforms don't re-measure.createEmoji:measureEmojiBounds(char, fontSize).applyTransform(emoji branch):newFs, the bg is sized frommeasureEmojiBounds(char, newFs)instead ofemojiText.width() / height().Per-emoji result
Notes
(char, fontSize). A few hundred entries at most for a busy board; memory is negligible.actualBoundingBox*may return zeros. We fall back tomeasureText().widthandfontSizeso the bg is at least sensible; subsequent transforms re-measure with the loaded font.Iteration 2: pixel-scan the rendered glyph
Canvas's
actualBoundingBox*was returning the font's metric box (full em-square + descender slot) for emoji on this browser, not the visible glyph extent — hence the over-padded height in iteration 1.Switched to a definitive pixel-scan: render the emoji to an offscreen canvas, walk the alpha channel, and take the bounding rect of all non-transparent pixels. Cached per
(char, fontSize)so repeat transforms are free.Tight outline
Just sizing the bg to the scanned bounds isn't enough — the textNode's own layout box (fontSize tall) would still inflate
group.getClientRect(), which the transformer reads. So both at create time and on resize, we now:{ width, height, offsetX, offsetY }.(-offsetX, -offsetY)so the visible glyph appears at group (0, 0).textNode.width()/textNode.height()so the layout box exactly matches the visible bounds.group.getClientRect()now returns the visible-glyph rectangle exactly; the transformer outline hugs the rendered emoji on every type tested (heart, red square, smileys).Notes
(char, fontSize). ~one entry per (emoji, resize step), negligible memory.fontSize × fontSizewithout caching, so a later measurement gets the real bounds.Status: reverting all #123 attempts
I tried three approaches for tightening the emoji selection outline and each had a different regression:
lineHeight: 1.2on the text node — covers the heart-style overflow, but visibly over-pads emojis like the red square.Canvas.measureText().actualBoundingBox*— the browser was reporting the font's metric box (full em-square) instead of the glyph for emoji, so the height came out as~1.3 ×the visible glyph.textNode.width / height / x / y— pixel-scan returns accurate bounds, but constraining the text node's layout box while shifting its position interacts badly with how Konva.Text places glyphs, and emoji rendering broke (couldn't place new emojis or see existing ones).Reverted all changes; the file is back to the state before this issue. Selection outline is still loose for the heart-style overflow, but emoji creation / loading works again.
Possible follow-ups (out of scope for this PR)
Konva.TextwithKonva.Imagerendering a pre-rendered emoji bitmap. The Image'swidth / heightthen exactly matches the visible glyph and the transformer outline is tight by construction. Bigger refactor; changes how emojis are stored / synced.Will leave this open; user to decide.
Iteration 3: top-only padding via text node Y-shift
After confirming with the user that the original code's outline was correct on left / right / bottom — only the top needed headroom to cover emoji that spill above the text layout box — the fix is now minimal and surgical.
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.jsBoth
createEmojiandapplyTransformnow shift the text node down byMath.round(fontSize * 0.1)and grow the bg by the same amount. The visible glyph appears aty = topPad; the bg covers(0, 0) .. (width, height + topPad). Result: outline gains ~10% headroom above the glyph; bottom and sides stay flush.The reload path (when
opts.widthis supplied) recomputes the topPad at the resolved fontSize and applies the same shift / size.Why this works where iterations 1 and 2 didn't
lineHeight: 1.2): padded all four sides — bottom of red square got a visible gap.actualBoundingBox*): browser reports font metric box for emoji, not glyph; over-padded height anyway.Konva.Textinterfered with rendering; emojis stopped appearing.This iteration only translates the text node and resizes the bg — neither of which touches Konva.Text's internal layout calculation. Konva renders the glyph from its
.x()/.y()position normally; the bg just covers extra space above. No Konva.Text width/height tweaks, no pixel-scan, no offscreen canvas.Notes
topPadscales with fontSize so the headroom looks proportional whether the emoji is 48 px or 600 px.obj.heightfrom before wastextNode.height(); after this fix it will betextNode.height() + topPadonce the user re-saves. The reload path usesopts.widthfor fontSize, notopts.height, so the visual size stays the same regardless.