From 99378dc02d2b7d19352fbdd111f20d797aaf9450 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 1 Sep 2026 15:48:09 +0200 Subject: [PATCH] :bug: Fix font preview in assets breaks the font row (#11428) * :bug: Fix font preview in assets breaks the font row * :bug: Fix font height problem also in the font dropdown * :bug: Fix a small bug within the changes --------- Co-authored-by: Eva Marco --- frontend/src/app/main/ui/dashboard/grid.scss | 5 + .../sidebar/options/menus/typography.cljs | 150 +++++++++++++++--- .../sidebar/options/menus/typography.scss | 4 + frontend/src/app/util/dom.cljs | 30 ++++ 4 files changed, 169 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index a64e898019..832da19306 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -419,9 +419,14 @@ $thumbnail-default-height: px2rem(168); } .library-typography-sample { + display: flex; + justify-content: center; + align-items: center; block-size: px2rem(20); + line-height: 1; margin-inline-end: var(--sp-xs); inline-size: px2rem(20); + overflow: hidden; } // ─── MISC ────────────────────────────────────── diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 53a5a26b01..3d41372e2c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -90,6 +90,97 @@ (constantly nil))))) @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* "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 @@ -110,7 +201,18 @@ ;; we show the plain name rather than runtime-loading the whole catalog. in-sprite? (and attached? (contains? (:ids sprite) font-id)) 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? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. [:svg {:class (stl/css :font-item-preview) @@ -118,8 +220,11 @@ :aria-label (:name font)} [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] [:span {:class (stl/css :font-item-label) - :style (when loaded? - #js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})} + :style (cond-> {} + loaded? + (assoc :font-family (dm/str "\"" (:family font) "\", sans-serif")) + (not (zero? label-offset)) + (assoc :transform (dm/str "translateY(" label-offset "em)")))} (:name font)]))) (mf/defc font-item* @@ -590,6 +695,11 @@ font-data (fonts/get-font-data (:font-id typography)) typography-id (:id typography) 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 (mf/use-fn @@ -624,10 +734,9 @@ [:* [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -661,11 +770,9 @@ [:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-sample) - - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :typography-name) :title (:name typography)} @@ -712,6 +819,11 @@ open? (deref open*) font-data (fonts/get-font-data (:font-id typography)) 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 (mf/use-fn @@ -769,10 +881,9 @@ [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -789,10 +900,9 @@ :on-context-menu on-context-menu} [:div {:class (stl/css :typography-sample) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :name-block) :title (if name-only? diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 967e9d7c0f..99fd0a398f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -119,6 +119,8 @@ $font-preview-box-height: 28px; inline-size: $sz-24; block-size: 100%; font-size: px2rem(16); + line-height: 1; + overflow: hidden; color: var(--color-foreground-primary); } @@ -171,8 +173,10 @@ $font-preview-box-height: 28px; align-items: center; min-inline-size: $sz-24; font-size: px2rem(16); + line-height: 1; block-size: $sz-32; padding: 0; + overflow: hidden; color: var(--color-foreground-primary); } diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 5995a51264..8049af8a62 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -949,6 +949,36 @@ {:ascent (.-fontBoundingBoxAscent 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 ([^js node] (clone-node node true))