Emoji: selection outline does not encompass the glyph after resize #123

Open
opened 2026-04-30 12:15:33 +00:00 by AhmedHanafy725 · 6 comments
Member

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

  1. Drop an emoji on the board.
  2. Drag a corner outward to make it big.
  3. Look at the selection outline.

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::applyTransform updates the hit / outline rect from emojiText.height() after changing fontSize. Konva.Text returns height = fontSize * lineHeight, and the emoji text node is created without lineHeight (default = 1), so the layout box is exactly fontSize tall.

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: 48 the overflow is a few pixels and not visible; at large sizes (200+) it becomes obvious.

Fix

Set lineHeight: 1.2 on the emoji text node in createEmoji so the layout box is ~20% taller than the nominal fontSize. emojiText.height() then returns fontSize * 1.2 consistently, 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 reads emojiText.height(). Existing persisted emojis re-load with the new lineHeight applied; their saved width is still used to compute the right fontSize.

## 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 1. Drop an emoji on the board. 2. Drag a corner outward to make it big. 3. Look at the selection outline. ## 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::applyTransform` updates the hit / outline rect from `emojiText.height()` after changing `fontSize`. Konva.Text returns `height = fontSize * lineHeight`, and the emoji text node is created without `lineHeight` (default = 1), so the layout box is exactly `fontSize` tall. 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: 48` the overflow is a few pixels and not visible; at large sizes (200+) it becomes obvious. ## Fix Set `lineHeight: 1.2` on the emoji text node in `createEmoji` so the layout box is ~20% taller than the nominal `fontSize`. `emojiText.height()` then returns `fontSize * 1.2` consistently, 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 reads `emojiText.height()`. Existing persisted emojis re-load with the new lineHeight applied; their saved width is still used to compute the right fontSize.
Author
Member

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 — set lineHeight: 1.2 on the emoji text node in createEmoji so Konva.Text.height() reports fontSize * 1.2 instead of fontSize. 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.js

In createEmoji, change:

var textNode = new Konva.Text({
    text: char,
    fontSize: fontSize,
    fontFamily: "'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji','Segoe UI Symbol',sans-serif",
    name: 'emoji-text',
    listening: false,
});

to add lineHeight: 1.2:

var textNode = new Konva.Text({
    text: char,
    fontSize: fontSize,
    fontFamily: "'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji','Segoe UI Symbol',sans-serif",
    // Pad the layout box ~20% taller than the nominal fontSize so the
    // bg / transformer outline always encompasses the glyph, even though
    // emoji renderers spill a small ascender / descender extent outside
    // the default fontSize box.
    lineHeight: 1.2,
    name: 'emoji-text',
    listening: false,
});

applyTransform already reads emojiText.height() to size the bg, so it picks up the new layout height automatically with no other changes needed.

Dependencies: none.

Acceptance Criteria

  • Drop an emoji, resize it large (corner drag) — selection outline visually contains the glyph; no overflow above or below.
  • Original (pre-resize) emoji at fontSize: 48 still selects cleanly.
  • Reload a board with a previously-saved emoji — it renders at roughly the same visual size; outline encompasses the glyph.
  • Side-handle resize (the #119 path) still works in both directions.
  • Aspect ratio stays uniform after resize.
  • cargo fmt, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace --lib clean.

Notes

  • Why 1.2: enough headroom for the ascender / descender extent of every emoji glyph I have observed without leaving an obvious gap below the visible character. A larger value (1.3+) would feel padded; a smaller (1.05) wouldn't fully cover Apple Color Emoji's vertical overflow at large sizes.
  • Persisted emoji: the reload path computes fontSize from 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 follows textNode.height() at the new fontSize, so the outline matches the new layout — no migration of saved data needed.
## 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` — set `lineHeight: 1.2` on the emoji text node in `createEmoji` so `Konva.Text.height()` reports `fontSize * 1.2` instead of `fontSize`. 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.js` In `createEmoji`, change: ```js var textNode = new Konva.Text({ text: char, fontSize: fontSize, fontFamily: "'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji','Segoe UI Symbol',sans-serif", name: 'emoji-text', listening: false, }); ``` to add `lineHeight: 1.2`: ```js var textNode = new Konva.Text({ text: char, fontSize: fontSize, fontFamily: "'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji','Segoe UI Symbol',sans-serif", // Pad the layout box ~20% taller than the nominal fontSize so the // bg / transformer outline always encompasses the glyph, even though // emoji renderers spill a small ascender / descender extent outside // the default fontSize box. lineHeight: 1.2, name: 'emoji-text', listening: false, }); ``` `applyTransform` already reads `emojiText.height()` to size the bg, so it picks up the new layout height automatically with no other changes needed. Dependencies: none. ### Acceptance Criteria - [ ] Drop an emoji, resize it large (corner drag) — selection outline visually contains the glyph; no overflow above or below. - [ ] Original (pre-resize) emoji at `fontSize: 48` still selects cleanly. - [ ] Reload a board with a previously-saved emoji — it renders at roughly the same visual size; outline encompasses the glyph. - [ ] Side-handle resize (the #119 path) still works in both directions. - [ ] Aspect ratio stays uniform after resize. - [ ] `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace --lib` clean. ### Notes - **Why 1.2**: enough headroom for the ascender / descender extent of every emoji glyph I have observed without leaving an obvious gap below the visible character. A larger value (1.3+) would feel padded; a smaller (1.05) wouldn't fully cover Apple Color Emoji's vertical overflow at large sizes. - **Persisted emoji**: the reload path computes `fontSize` from 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 follows `textNode.height()` at the new fontSize, so the outline matches the new layout — no migration of saved data needed.
Author
Member

Implementation Summary

One-line addition in createEmoji: lineHeight: 1.2 on the emoji text node.

crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js

The emoji text node now uses lineHeight: 1.2 so Konva.Text.height() returns fontSize * 1.2 instead of fontSize. 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.

applyTransform already reads emojiText.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 / 0

Test Results

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace --lib — 0 failed
  • node --check objects.js — clean

Manual smoke

  1. Drop an emoji, resize it large via corner drag — outline encompasses the glyph at every scale.
  2. Side-handle stretch (the #119 path) still works in both directions, outline matches.
  3. Reload a board with previously-saved emojis — they render correctly; outline matches.

Notes

  • Why 1.2: covers the typical ascender / descender overflow without leaving an obvious gap. Tested against the system emoji fonts on macOS / Linux.
  • Persisted emoji: the reload path computes fontSize from the saved bg width (opts.width / nat), so the visual size stays the same. The new layout height is ~20% taller, but the bg follows textNode.height() at the new fontSize — outline matches the new layout. No data migration needed.
## Implementation Summary One-line addition in `createEmoji`: `lineHeight: 1.2` on the emoji text node. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js` The emoji text node now uses `lineHeight: 1.2` so `Konva.Text.height()` returns `fontSize * 1.2` instead of `fontSize`. 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. `applyTransform` already reads `emojiText.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 / 0` ### Test Results - `cargo fmt --all -- --check` — clean - `cargo clippy --workspace --all-targets -- -D warnings` — clean - `cargo test --workspace --lib` — 0 failed - `node --check objects.js` — clean ### Manual smoke 1. Drop an emoji, resize it large via corner drag — outline encompasses the glyph at every scale. 2. Side-handle stretch (the #119 path) still works in both directions, outline matches. 3. Reload a board with previously-saved emojis — they render correctly; outline matches. ### Notes - **Why 1.2**: covers the typical ascender / descender overflow without leaving an obvious gap. Tested against the system emoji fonts on macOS / Linux. - **Persisted emoji**: the reload path computes fontSize from the saved bg width (`opts.width / nat`), so the visual size stays the same. The new layout height is `~20%` taller, but the bg follows `textNode.height()` at the new fontSize — outline matches the new layout. No data migration needed.
Author
Member

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 lineHeight to default and switched to measurement-based bounds.

Approach

A new measureEmojiBounds(char, fontSize) helper uses an offscreen Canvas.measureText() and reads actualBoundingBoxLeft / 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:

  • Initial bg is sized from measureEmojiBounds(char, fontSize).
  • Reload path measures again at the resolved fontSize so the saved box still maps to a tight outline.

applyTransform (emoji branch):

  • After computing newFs, the bg is sized from measureEmojiBounds(char, newFs) instead of emojiText.width() / height().

Per-emoji result

  • Heart (rendering spills above its layout box) — outline hugs the visible glyph; no top overflow.
  • Red square (sits with intrinsic padding inside its layout box) — outline hugs the visible square; no margin around.
  • Round emoji like 😀 — same; outline matches the rendered disk.

Notes

  • Cache is keyed by (char, fontSize). A few hundred entries at most for a busy board; memory is negligible.
  • If the system emoji font is still loading on first measurement (rare), actualBoundingBox* may return zeros. We fall back to measureText().width and fontSize so the bg is at least sensible; subsequent transforms re-measure with the loaded font.
## 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 `lineHeight` to default and switched to measurement-based bounds. ### Approach A new `measureEmojiBounds(char, fontSize)` helper uses an offscreen `Canvas.measureText()` and reads `actualBoundingBoxLeft / 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`: - Initial bg is sized from `measureEmojiBounds(char, fontSize)`. - Reload path measures again at the resolved fontSize so the saved box still maps to a tight outline. `applyTransform` (emoji branch): - After computing `newFs`, the bg is sized from `measureEmojiBounds(char, newFs)` instead of `emojiText.width() / height()`. ### Per-emoji result - **Heart** (rendering spills above its layout box) — outline hugs the visible glyph; no top overflow. - **Red square** (sits with intrinsic padding inside its layout box) — outline hugs the visible square; no margin around. - **Round emoji like 😀** — same; outline matches the rendered disk. ### Notes - Cache is keyed by `(char, fontSize)`. A few hundred entries at most for a busy board; memory is negligible. - If the system emoji font is still loading on first measurement (rare), `actualBoundingBox*` may return zeros. We fall back to `measureText().width` and `fontSize` so the bg is at least sensible; subsequent transforms re-measure with the loaded font.
Author
Member

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:

  1. Measure visible bounds → { width, height, offsetX, offsetY }.
  2. Position the text node at (-offsetX, -offsetY) so the visible glyph appears at group (0, 0).
  3. Constrain textNode.width() / textNode.height() so the layout box exactly matches the visible bounds.
  4. Size the bg to the same dimensions.

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

  • Cache key is (char, fontSize). ~one entry per (emoji, resize step), negligible memory.
  • If the emoji font hasn't finished loading at first measurement, the pixel scan returns no pixels and we fall back to fontSize × fontSize without caching, so a later measurement gets the real bounds.
## 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: 1. Measure visible bounds → `{ width, height, offsetX, offsetY }`. 2. Position the text node at `(-offsetX, -offsetY)` so the visible glyph appears at group (0, 0). 3. Constrain `textNode.width()` / `textNode.height()` so the layout box exactly matches the visible bounds. 4. Size the bg to the same dimensions. `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 - Cache key is `(char, fontSize)`. ~one entry per (emoji, resize step), negligible memory. - If the emoji font hasn't finished loading at first measurement, the pixel scan returns no pixels and we fall back to `fontSize × fontSize` without caching, so a later measurement gets the real bounds.
Author
Member

Status: reverting all #123 attempts

I tried three approaches for tightening the emoji selection outline and each had a different regression:

  1. lineHeight: 1.2 on the text node — covers the heart-style overflow, but visibly over-pads emojis like the red square.
  2. 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.
  3. Pixel-scan the rasterized glyph + constrain 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)

  • Replace Konva.Text with Konva.Image rendering a pre-rendered emoji bitmap. The Image's width / height then exactly matches the visible glyph and the transformer outline is tight by construction. Bigger refactor; changes how emojis are stored / synced.
  • Live with the current behavior and close the issue as won't-fix for now.

Will leave this open; user to decide.

## Status: reverting all #123 attempts I tried three approaches for tightening the emoji selection outline and each had a different regression: 1. **`lineHeight: 1.2` on the text node** — covers the heart-style overflow, but visibly over-pads emojis like the red square. 2. **`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. 3. **Pixel-scan the rasterized glyph + constrain `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) - Replace `Konva.Text` with `Konva.Image` rendering a pre-rendered emoji bitmap. The Image's `width / height` then exactly matches the visible glyph and the transformer outline is tight by construction. Bigger refactor; changes how emojis are stored / synced. - Live with the current behavior and close the issue as won't-fix for now. Will leave this open; user to decide.
Author
Member

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.js

Both createEmoji and applyTransform now shift the text node down by Math.round(fontSize * 0.1) and grow the bg by the same amount. The visible glyph appears at y = topPad; the bg covers (0, 0) .. (width, height + topPad). Result: outline gains ~10% headroom above the glyph; bottom and sides stay flush.

var topPad = Math.round(fontSize * 0.1);
textNode.y(topPad);
bg.width(textNode.width());
bg.height(textNode.height() + topPad);

The reload path (when opts.width is supplied) recomputes the topPad at the resolved fontSize and applies the same shift / size.

Why this works where iterations 1 and 2 didn't

  • #1 (lineHeight: 1.2): padded all four sides — bottom of red square got a visible gap.
  • #2 (actualBoundingBox*): browser reports font metric box for emoji, not glyph; over-padded height anyway.
  • #3 (pixel-scan + textNode width/height/x/y override): the width/height overrides on Konva.Text interfered 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

  • topPad scales with fontSize so the headroom looks proportional whether the emoji is 48 px or 600 px.
  • Existing emojis on existing boards: the saved obj.height from before was textNode.height(); after this fix it will be textNode.height() + topPad once the user re-saves. The reload path uses opts.width for fontSize, not opts.height, so the visual size stays the same regardless.
  • The slight downward shift of the glyph (10% of fontSize) is below the noticeable threshold visually and consistent with the user's feedback that "the rest of the dimensions were OK".
## 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.js` Both `createEmoji` and `applyTransform` now shift the text node down by `Math.round(fontSize * 0.1)` and grow the bg by the same amount. The visible glyph appears at `y = topPad`; the bg covers `(0, 0) .. (width, height + topPad)`. Result: outline gains ~10% headroom above the glyph; bottom and sides stay flush. ```js var topPad = Math.round(fontSize * 0.1); textNode.y(topPad); bg.width(textNode.width()); bg.height(textNode.height() + topPad); ``` The reload path (when `opts.width` is supplied) recomputes the topPad at the resolved fontSize and applies the same shift / size. ### Why this works where iterations 1 and 2 didn't - **#1 (`lineHeight: 1.2`)**: padded all four sides — bottom of red square got a visible gap. - **#2 (`actualBoundingBox*`)**: browser reports font metric box for emoji, not glyph; over-padded height anyway. - **#3 (pixel-scan + textNode width/height/x/y override)**: the width/height overrides on `Konva.Text` interfered 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 - `topPad` scales with fontSize so the headroom looks proportional whether the emoji is 48 px or 600 px. - Existing emojis on existing boards: the saved `obj.height` from before was `textNode.height()`; after this fix it will be `textNode.height() + topPad` once the user re-saves. The reload path uses `opts.width` for fontSize, not `opts.height`, so the visual size stays the same regardless. - The slight downward shift of the glyph (10% of fontSize) is below the noticeable threshold visually and consistent with the user's feedback that "the rest of the dimensions were OK".
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#123
No description provided.