🐛 Fix font preview in assets breaks the font row (#11428)

* 🐛 Fix font preview in assets breaks the font row

* 🐛 Fix font height problem also in the font dropdown

* 🐛 Fix a small bug within the changes

---------

Co-authored-by: Eva Marco <evamarcod@gmail.com>
This commit is contained in:
Luis de Dios 2026-09-01 15:48:09 +02:00 committed by Alejandro Alonso
parent f22abc9861
commit a19b3c8d62
4 changed files with 169 additions and 20 deletions

View File

@ -419,9 +419,14 @@ $thumbnail-default-height: px2rem(168);
} }
.library-typography-sample { .library-typography-sample {
display: flex;
justify-content: center;
align-items: center;
block-size: px2rem(20); block-size: px2rem(20);
line-height: 1;
margin-inline-end: var(--sp-xs); margin-inline-end: var(--sp-xs);
inline-size: px2rem(20); inline-size: px2rem(20);
overflow: hidden;
} }
// MISC // MISC

View File

@ -90,6 +90,97 @@
(constantly nil))))) (constantly nil)))))
@loaded?)) @loaded?))
;; --- OPTICAL CENTERING OF SAMPLE TEXT --------------------------------------
;; Fonts with exaggerated vertical metrics (huge ascender/descender, small
;; caps) render their line box lower within a fixed-height row, so a plain
;; `align-items: center` leaves the visible glyphs sitting low. We measure the
;; font-wide vs glyph-ink bounding boxes once per font/sample and shift the
;; text by the computed offset so the visible glyphs are optically centered.
;; The offset is expressed in `em`, which makes it size-independent: the same
;; measurement corrects both the 16px `Ag` sample and the smaller font-name
;; labels in the font selector.
(defonce ^:private optical-offset-cache (atom {}))
(defn- optical-offset-key [family weight style text]
(dm/str family "|" weight "|" style "|" text))
(defn- optical-offset-em
"Vertical shift (in `em` units, i.e. relative to the font size) that centers
the ink of `text` within a single line box.
For a centered line the shift reduces to the difference between the font-wide
and ink bounding boxes:
dy = ((ink-ascent - font-ascent) + (font-descent - ink-descent)) / 2.
Measuring at 16px and dividing the pixel shift by it yields the `em` value."
[family weight style text]
(when-some [{:keys [font-ascent font-descent ink-ascent ink-descent]}
(dom/measure-text-metrics family weight style text 16)]
(let [dy (/ (+ (- ink-ascent font-ascent)
(- font-descent ink-descent))
2)
em (/ dy 16)]
;; Round to avoid float noise leaking into the transform string.
(/ (js/Math.round (* em 10000)) 10000))))
(defn- load-optical-offset
[font-id family weight style text]
(let [key (optical-offset-key family weight style text)]
(if-let [cached (get @optical-offset-cache key)]
(p/resolved cached)
(-> (fonts/ensure-loaded! font-id)
(p/then
(fn [_]
(let [em (or (optical-offset-em family weight style text) 0)]
(swap! optical-offset-cache assoc key em)
em)))))))
(defn- use-optical-offset
"Lazily resolve the optical-centering offset (in `em`) for sample text in a
given font, measuring once per font/sample and caching it. Falls back to 0
when the font isn't available or the metrics can't be measured."
[font-id family weight style text]
(let [offset* (mf/use-state 0)]
(mf/use-effect
(mf/deps font-id family weight style text)
(fn []
(let [cancelled? (volatile! false)
key (optical-offset-key family weight style text)]
(if (contains? @optical-offset-cache key)
(reset! offset* (get @optical-offset-cache key))
(let [task (tm/schedule-on-idle
(fn []
(-> (load-optical-offset font-id family weight style text)
(p/then
(fn [em]
(when-not @cancelled?
(reset! offset* em)))))))]
(fn []
(vreset! cancelled? true)
(tm/dispose! task)))))
nil))
(deref offset*)))
(defn- sample-container-style
"Inline style that applies the typography font to the (clipped, fixed-height)
sample container."
[typography]
{:font-family (:font-family typography)
:font-weight (:font-weight typography)
:font-style (:font-style typography)})
(defn- sample-text-style
"Inline style that optically centers the sample glyphs. Must be applied to
the text node itself, not to the clipped container: a transform on an
`overflow: hidden` element moves its own clip region along with it, so it
would shift the whole box relative to the row instead of the glyphs inside it."
[em]
(when-not (zero? em)
{:transform (dm/str "translateY(" em "em)")}))
;; --- FONT SELECTOR --------------------------------------------------------
(mf/defc font-item-preview* (mf/defc font-item-preview*
"Row content with previews: a vector preview from the shared sprite for catalog "Row content with previews: a vector preview from the shared sprite for catalog
fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't
@ -110,7 +201,18 @@
;; we show the plain name rather than runtime-loading the whole catalog. ;; we show the plain name rather than runtime-loading the whole catalog.
in-sprite? (and attached? (contains? (:ids sprite) font-id)) in-sprite? (and attached? (contains? (:ids sprite) font-id))
fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?)) fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?))
loaded? (use-font-lazy-load font-id fallback?)] loaded? (use-font-lazy-load font-id fallback?)
;; Optical centering for the fallback name (custom fonts the sprite
;; doesn't cover): extreme vertical metrics would push the name low in
;; the row, so shift it by the measured offset once the font is known.
;; The label renders at `body-medium` (400/normal), which is the weight
;; and style we measure against.
label-offset (use-optical-offset font-id
(:family font)
"400"
"normal"
(:name font))]
(if in-sprite? (if in-sprite?
;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color.
[:svg {:class (stl/css :font-item-preview) [:svg {:class (stl/css :font-item-preview)
@ -118,8 +220,11 @@
:aria-label (:name font)} :aria-label (:name font)}
[:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]]
[:span {:class (stl/css :font-item-label) [:span {:class (stl/css :font-item-label)
:style (when loaded? :style (cond-> {}
#js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})} loaded?
(assoc :font-family (dm/str "\"" (:family font) "\", sans-serif"))
(not (zero? label-offset))
(assoc :transform (dm/str "translateY(" label-offset "em)")))}
(:name font)]))) (:name font)])))
(mf/defc font-item* (mf/defc font-item*
@ -590,6 +695,11 @@
font-data (fonts/get-font-data (:font-id typography)) font-data (fonts/get-font-data (:font-id typography))
typography-id (:id typography) typography-id (:id typography)
show-actions? (and is-asset? is-editable) show-actions? (and is-asset? is-editable)
offset (use-optical-offset (:font-id typography)
(:font-family typography)
(:font-weight typography)
(:font-style typography)
"Ag")
on-delete on-delete
(mf/use-fn (mf/use-fn
@ -624,10 +734,9 @@
[:* [:*
[:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :font-name-wrapper)}
[:div {:class (stl/css :typography-sample-input) [:div {:class (stl/css :typography-sample-input)
:style {:font-family (:font-family typography) :style (sample-container-style typography)}
:font-weight (:font-weight typography) [:span {:style (sample-text-style offset)}
:font-style (:font-style typography)}} (tr "workspace.assets.typography.sample")]]
(tr "workspace.assets.typography.sample")]
[:input [:input
{:class (stl/css :adv-typography-name) {:class (stl/css :adv-typography-name)
@ -661,11 +770,9 @@
[:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-info-wrapper)}
[:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-name-wrapper)}
[:div {:class (stl/css :typography-sample) [:div {:class (stl/css :typography-sample)
:style (sample-container-style typography)}
:style {:font-family (:font-family typography) [:span {:style (sample-text-style offset)}
:font-weight (:font-weight typography) (tr "workspace.assets.typography.sample")]]
:font-style (:font-style typography)}}
(tr "workspace.assets.typography.sample")]
[:div {:class (stl/css :typography-name) [:div {:class (stl/css :typography-name)
:title (:name typography)} :title (:name typography)}
@ -712,6 +819,11 @@
open? (deref open*) open? (deref open*)
font-data (fonts/get-font-data (:font-id typography)) font-data (fonts/get-font-data (:font-id typography))
name-only? (= (:name typography) (:name font-data)) name-only? (= (:name typography) (:name font-data))
offset (use-optical-offset (:font-id typography)
(:font-family typography)
(:font-weight typography)
(:font-style typography)
"Ag")
on-name-blur on-name-blur
(mf/use-fn (mf/use-fn
@ -769,10 +881,9 @@
[:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :font-name-wrapper)}
[:div [:div
{:class (stl/css :typography-sample-input) {:class (stl/css :typography-sample-input)
:style {:font-family (:font-family typography) :style (sample-container-style typography)}
:font-weight (:font-weight typography) [:span {:style (sample-text-style offset)}
:font-style (:font-style typography)}} (tr "workspace.assets.typography.sample")]]
(tr "workspace.assets.typography.sample")]
[:input [:input
{:class (stl/css :adv-typography-name) {:class (stl/css :adv-typography-name)
@ -789,10 +900,9 @@
:on-context-menu on-context-menu} :on-context-menu on-context-menu}
[:div [:div
{:class (stl/css :typography-sample) {:class (stl/css :typography-sample)
:style {:font-family (:font-family typography) :style (sample-container-style typography)}
:font-weight (:font-weight typography) [:span {:style (sample-text-style offset)}
:font-style (:font-style typography)}} (tr "workspace.assets.typography.sample")]]
(tr "workspace.assets.typography.sample")]
[:div {:class (stl/css :name-block) [:div {:class (stl/css :name-block)
:title (if name-only? :title (if name-only?

View File

@ -119,6 +119,8 @@ $font-preview-box-height: 28px;
inline-size: $sz-24; inline-size: $sz-24;
block-size: 100%; block-size: 100%;
font-size: px2rem(16); font-size: px2rem(16);
line-height: 1;
overflow: hidden;
color: var(--color-foreground-primary); color: var(--color-foreground-primary);
} }
@ -171,8 +173,10 @@ $font-preview-box-height: 28px;
align-items: center; align-items: center;
min-inline-size: $sz-24; min-inline-size: $sz-24;
font-size: px2rem(16); font-size: px2rem(16);
line-height: 1;
block-size: $sz-32; block-size: $sz-32;
padding: 0; padding: 0;
overflow: hidden;
color: var(--color-foreground-primary); color: var(--color-foreground-primary);
} }

View File

@ -949,6 +949,36 @@
{:ascent (.-fontBoundingBoxAscent measure) {:ascent (.-fontBoundingBoxAscent measure)
:descent (.-fontBoundingBoxDescent measure)})) :descent (.-fontBoundingBoxDescent measure)}))
(defn measure-text-metrics
"Measure the font-wide (bounding-box) and glyph-ink vertical metrics of `text`
at `font-size` px for the given font.
Returns `{:font-ascent :font-descent :ink-ascent :ink-descent}` in px, or nil
when the browser doesn't expose the bounding-box metrics. The font-wide
values track what CSS uses for the line box, while the ink ones track the
visible glyphs, which is what an optical centering shift needs."
([family weight style]
(measure-text-metrics family weight style "Ag" 16))
([family weight style text font-size]
(let [element (.createElement globals/document "canvas")
context (.getContext element "2d")
_ (set! (.-font context)
(dm/str (or weight "400") " " (or style "normal") " "
font-size "px \"" family "\""))
measure ^js (.measureText context (str text))
font-ascent (.-fontBoundingBoxAscent measure)
font-descent (.-fontBoundingBoxDescent measure)
ink-ascent (.-actualBoundingBoxAscent measure)
ink-descent (.-actualBoundingBoxDescent measure)]
(when (and (number? font-ascent)
(number? font-descent)
(number? ink-ascent)
(number? ink-descent))
{:font-ascent font-ascent
:font-descent font-descent
:ink-ascent ink-ascent
:ink-descent ink-descent}))))
(defn clone-node (defn clone-node
([^js node] ([^js node]
(clone-node node true)) (clone-node node true))