🐛 Fix font change regression and activate font preview by default (#11341)

*  Materialize font-preview flag to make it permanent

* 🐛 Fix font change regression when WebGL is disabled

* 📎 Add fixes for e2e tests

* 🐛 Fix font change from legacy render

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
This commit is contained in:
Andrey Antukh 2026-08-27 18:30:33 +02:00 committed by GitHub
parent 6bedd5ac76
commit 43bbf756b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 95 additions and 97 deletions

View File

@ -569,7 +569,7 @@ test.describe("Tokens - creation", () => {
}); });
await selectDropdown.click(); await selectDropdown.click();
const fontOption = tokensUpdateCreateModal.getByText("ABeeZee"); const fontOption = tokensUpdateCreateModal.getByRole('img', { name: 'ABeeZee' })
await expect(fontOption).toBeVisible(); await expect(fontOption).toBeVisible();
await fontOption.click(); await fontOption.click();
@ -583,7 +583,7 @@ test.describe("Tokens - creation", () => {
name: "Search font", name: "Search font",
}); });
await searchField.fill("alme"); await searchField.fill("alme");
const fontOption2 = tokensUpdateCreateModal.getByText("Almendra Display"); const fontOption2 = tokensUpdateCreateModal.getByRole('img', {name: "Almendra Display"});
await expect(fontOption2).toBeVisible(); await expect(fontOption2).toBeVisible();
await fontOption2.click(); await fontOption2.click();
@ -1521,7 +1521,7 @@ test.describe("Tokens - creation", () => {
}); });
await selectDropdown.click(); await selectDropdown.click();
const fontOption = tokensUpdateCreateModal.getByText("ABeeZee"); const fontOption = tokensUpdateCreateModal.getByRole("img", {name: "ABeeZee"});
await expect(fontOption).toBeVisible(); await expect(fontOption).toBeVisible();
await fontOption.click(); await fontOption.click();
@ -1536,7 +1536,7 @@ test.describe("Tokens - creation", () => {
name: "Search font", name: "Search font",
}); });
await searchField.fill("alme"); await searchField.fill("alme");
const fontOption2 = tokensUpdateCreateModal.getByText("Almendra Display"); const fontOption2 = tokensUpdateCreateModal.getByRole("img", {name: "Almendra Display"});
await expect(fontOption2).toBeVisible(); await expect(fontOption2).toBeVisible();
await fontOption2.click(); await fontOption2.click();
await expect( await expect(

View File

@ -174,7 +174,12 @@
(rx/mapcat (fn [_] (rx/mapcat (fn [_]
(rx/from (fonts/ensure-loaded! font-id font-variant-id)))) (rx/from (fonts/ensure-loaded! font-id font-variant-id))))
(rx/take-until (text-work-stopper stream)) (rx/take-until (text-work-stopper stream))
(rx/ignore) (rx/mapcat (fn [_]
(st/emit! (dwsh/update-shapes
ids
#(dissoc % :position-data)
{:save-undo? false}))
(rx/empty)))
(wrf/with-pending :font ids)))) (wrf/with-pending :font ids))))
;; -- Content helpers ;; -- Content helpers
@ -850,16 +855,16 @@
::resize-text-debounce-event))))) ::resize-text-debounce-event)))))
(rx/empty)))))) (rx/empty))))))
(defn save-font (defn save-default-font
[data] [data]
(ptk/reify ::save-font (ptk/reify ::save-default-font
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(let [multiple? (->> data vals (d/seek #(= % :multiple)))] (let [multiple? (->> data vals (d/seek #(= % :multiple)))
font (dissoc data :typography-ref-id :typography-ref-file)]
(cond-> state (cond-> state
(not multiple?) (not multiple?)
(assoc-in [:workspace-global :default-font] (update :workspace-global assoc :default-font font))))))
(dissoc data :typography-ref-id :typography-ref-file)))))))
(defn apply-text-modifier (defn apply-text-modifier
[shape text-modifier] [shape text-modifier]

View File

@ -242,7 +242,7 @@
(when (zero? (:refs new-state)) (when (zero? (:refs new-state))
(dom/remove! node)))) (dom/remove! node))))
(defn- add-font-css! (defn- add-font-css
"Creates a style element and attaches it to the dom." "Creates a style element and attaches it to the dom."
[id css] [id css]
(let [node (dom/create-element "style")] (let [node (dom/create-element "style")]
@ -306,7 +306,7 @@
(->> (request-gfont-css url) (->> (request-gfont-css url)
(rx/map process-gfont-css) (rx/map process-gfont-css)
(rx/tap #(on-loaded id)) (rx/tap #(on-loaded id))
(rx/subs! (partial add-font-css! id) (rx/subs! (partial add-font-css id)
#(when (fn? on-failed) (on-failed %)))) #(when (fn? on-failed) (on-failed %))))
nil))) nil)))
@ -346,7 +346,7 @@
(when (globals/browser?) (when (globals/browser?)
(log/dbg :hint "load-font" :font-id id :backend "custom") (log/dbg :hint "load-font" :font-id id :backend "custom")
(let [css (generate-custom-font-css font)] (let [css (generate-custom-font-css font)]
(add-font-css! id css) (add-font-css id css)
(when (fn? on-loaded) (when (fn? on-loaded)
(on-loaded))))) (on-loaded)))))

View File

@ -8,7 +8,6 @@
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.config :as cf]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.persistence :as dps] [app.main.data.persistence :as dps]
@ -229,12 +228,8 @@
(st/emit! (dps/initialize-persistence) (st/emit! (dps/initialize-persistence)
(dpl/update-plugins-permissions-peek))) (dpl/update-plugins-permissions-peek)))
;; FLAG :font-preview — prefetch the preview sprite markup on workspace mount
;; (kept in memory, not the DOM) so the typography selector renders previews on
;; open with no network wait. Remove the flag check to drop the feature.
(mf/with-effect [] (mf/with-effect []
(when (contains? cf/flags :font-preview) (fonts/prefetch-preview-sprite!))
(fonts/prefetch-preview-sprite!)))
;; Setting the layout preset by its name ;; Setting the layout preset by its name
(mf/with-effect [layout-name] (mf/with-effect [layout-name]
@ -302,4 +297,3 @@
(when (uuid? file-id) (when (uuid? file-id)
[:> workspace* props]))) [:> workspace* props])))

View File

@ -30,18 +30,24 @@
;; --- Text Editor Rendering ;; --- Text Editor Rendering
(mf/defc block-component* (mf/defc block-component
[{:keys [block-props] :as props}] {::mf/wrap-props false}
(let [data (.-data ^js block-props) [props]
style (sts/generate-paragraph-styles (.-shape ^js block-props) data) (let [bprops (obj/get props "blockProps")
dir (:text-direction data "auto")] data (obj/get bprops "data")
style (sts/generate-paragraph-styles (obj/get bprops "shape")
(obj/get bprops "data"))
dir (:text-direction data "auto")]
[:div {:style style :dir dir} [:div {:style style :dir dir}
[:> draft/EditorBlock props]])) [:> draft/EditorBlock props]]))
(mf/defc selection-component* (mf/defc selection-component
[{:keys [children]}] {::mf/wrap-props false}
[:span {:style {:background "#ccc" :display "inline-block"}} children]) [props]
(let [children (obj/get props "children")]
[:span {:style {:background "#ccc" :display "inline-block"}} children]))
(defn- render-block (defn- render-block
[block shape] [block shape]
@ -49,7 +55,7 @@
(case type (case type
"unstyled" "unstyled"
#js {:editable true #js {:editable true
:component block-component* :component block-component
:props #js {:data (ted/get-editor-block-data block) :props #js {:data (ted/get-editor-block-data block)
:shape shape}} :shape shape}}
nil))) nil)))
@ -63,7 +69,7 @@
(sts/generate-text-styles shape data {:show-text? false}))) (sts/generate-text-styles shape data {:show-text? false})))
(def default-decorator (def default-decorator
(ted/create-decorator "PENPOT_SELECTION" selection-component*)) (ted/create-decorator "PENPOT_SELECTION" selection-component))
(def empty-editor-state (def empty-editor-state
(ted/create-editor-state nil default-decorator)) (ted/create-editor-state nil default-decorator))
@ -89,11 +95,12 @@
"bottom" "flex-end" "bottom" "flex-end"
nil)) nil))
(mf/defc text-shape-edit-html* (mf/defc text-shape-edit-html
{::mf/wrap [mf/memo] {::mf/wrap [mf/memo]
::mf/wrap-props false
::mf/forward-ref true} ::mf/forward-ref true}
[{:keys [shape]} _] [props _]
(let [{:keys [id content]} shape (let [{:keys [id content] :as shape} (obj/get props "shape")
state-map (mf/deref refs/workspace-editor-state) state-map (mf/deref refs/workspace-editor-state)
state (get state-map id empty-editor-state) state (get state-map id empty-editor-state)
@ -268,7 +275,8 @@
(-> (gpt/subtract pt box) (-> (gpt/subtract pt box)
(gpt/multiply zoom))))) (gpt/multiply zoom)))))
(mf/defc text-editor-svg* (mf/defc text-editor-svg
{::mf/wrap-props false}
[{:keys [shape modifiers]}] [{:keys [shape modifiers]}]
(let [shape-id (dm/get-prop shape :id) (let [shape-id (dm/get-prop shape :id)
modifiers (dm/get-in modifiers [shape-id :modifiers]) modifiers (dm/get-in modifiers [shape-id :modifiers])
@ -341,6 +349,6 @@
[:foreignObject {:x x :y y :width width :height height} [:foreignObject {:x x :y y :width width :height height}
[:div {:style style} [:div {:style style}
[:> text-shape-edit-html* [:& text-shape-edit-html
{:shape shape {:shape shape
:key (dm/str shape-id)}]]]])) :key (dm/str shape-id)}]]]]))

View File

@ -412,7 +412,7 @@
(select-keys txt/text-node-attrs))] (select-keys txt/text-node-attrs))]
(when (features/active-feature? @st/state "text-editor-wasm/v1") (when (features/active-feature? @st/state "text-editor-wasm/v1")
(st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs))) (st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs)))
(st/emit! (dwt/save-font updated-attrs) (st/emit! (dwt/save-default-font updated-attrs)
(dwt/update-all-attrs ids attrs))))) (dwt/update-all-attrs ids attrs)))))
on-change on-change

View File

@ -12,7 +12,6 @@
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.config :as cf]
[app.main.constants :refer [max-input-length]] [app.main.constants :refer [max-input-length]]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.fonts :as fts] [app.main.data.fonts :as fts]
@ -91,19 +90,13 @@
(constantly nil))))) (constantly nil)))))
@loaded?)) @loaded?))
;; --- FEATURE: font preview (flag :font-preview) ------------------------------
;; font-item-preview* and use-font-lazy-load are the whole feature. They are only
;; rendered/called behind the `:font-preview` flag check in font-item* below, so
;; their hooks never run when the flag is off. To remove the flag, inline
;; font-item-preview* into font-item* and drop the plain-name branch.
(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
cover." cover."
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[{:keys [font]}] [{:keys [font]}]
(let [font-id (:id font) (let [font-id (get font :id)
sprite (mf/deref fonts/preview-sprite) sprite (mf/deref fonts/preview-sprite)
;; The sprite is only referenceable once it's been attached to the DOM, ;; The sprite is only referenceable once it's been attached to the DOM,
@ -133,29 +126,20 @@
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[{:keys [font is-current on-click style]}] [{:keys [font is-current on-click style]}]
(let [item-ref (mf/use-ref) (let [item-ref (mf/use-ref)
on-click (mf/use-fn (mf/deps font) #(on-click font)) on-click (mf/use-fn (mf/deps font) #(on-click font))]
;; FLAG :font-preview — gates the feature markup AND its row styling
;; (.font-item-preview-on in the scss). Remove this and its two uses below.
preview? (contains? cf/flags :font-preview)]
(mf/use-effect (mf/with-effect [is-current]
(mf/deps is-current) (when is-current
(fn [] (let [element (mf/ref-val item-ref)]
(when is-current (when-not (dom/is-in-viewport? element)
(let [element (mf/ref-val item-ref)] (dom/scroll-into-view! element)))))
(when-not (dom/is-in-viewport? element)
(dom/scroll-into-view! element))))))
[:div {:class (stl/css :font-wrapper) [:div {:class (stl/css :font-wrapper)
:style style :style style
:ref item-ref :ref item-ref
:on-click on-click} :on-click on-click}
[:div {:class (stl/css-case :font-item true [:div {:class (stl/css-case :font-item true :selected is-current)}
:font-item-preview-on preview? [:> font-item-preview* {:font font}]
:selected is-current)}
(if preview?
[:> font-item-preview* {:font font}]
[:span {:class (stl/css :font-item-label)} (:name font)])
(when is-current (when is-current
[:> icon* {:icon-id i/tick [:> icon* {:icon-id i/tick
:size "s"}])]])) :size "s"}])]]))
@ -260,14 +244,13 @@
(let [key (events/listen js/document "keydown" on-key-down)] (let [key (events/listen js/document "keydown" on-key-down)]
#(events/unlistenByKey key))) #(events/unlistenByKey key)))
;; FLAG :font-preview — materialize the preview sprite into the DOM only while ;; Materialize the preview sprite into the DOM only while the picker is open
;; the picker is open (markup is prefetched on workspace load), removing it on ;; (markup is prefetched on workspace load), removing it on close so its
;; close so its ~2000 nodes aren't kept around idle. The attachment is deferred ;; ~2000 nodes aren't kept around idle. The attachment is deferred so the
;; so the dropdown can paint first with plain names, then the sprite swaps in ;; dropdown can paint first with plain names, then the sprite swaps in on the
;; on the next tick. Remove the flag clause to drop the feature. ;; next tick.
(mf/with-effect [sprite-status] (mf/with-effect [sprite-status]
(when (and (contains? cf/flags :font-preview) (when (= :ready sprite-status)
(= :ready sprite-status))
(let [node* (volatile! nil) (let [node* (volatile! nil)
task (tm/schedule task (tm/schedule
(fn [] (fn []
@ -278,9 +261,11 @@
(fonts/detach-preview-sprite! n)))))) (fonts/detach-preview-sprite! n))))))
(mf/with-effect [@selected] (mf/with-effect [@selected]
(when-let [inst (mf/ref-val flist)] (let [node (mf/ref-val flist)
(when-let [index (:index @selected)] index (:index @selected)]
(.scrollToRow ^js inst index)))) ;; This is nil safe operation, do nothing if node or index are
;; invalid.
(dom/scroll-to-row node index)))
(mf/with-effect [@selected] (mf/with-effect [@selected]
(on-select @selected)) (on-select @selected))
@ -291,11 +276,12 @@
(st/emit! (dsc/pop-shortcuts :typography)))) (st/emit! (dsc/pop-shortcuts :typography))))
(mf/with-effect [] (mf/with-effect []
(let [index (d/index-of-pred fonts #(= (:id %) (:id current-font))) (let [index (d/index-of-pred fonts #(= (:id %) (:id current-font)))
inst (mf/ref-val flist)] node (mf/ref-val flist)]
(tm/schedule (tm/schedule
#(let [offset (.getOffsetForRow ^js inst #js {:alignment "center" :index index})] #(let [offset (.getOffsetForRow ^js node #js {:alignment "center" :index index})]
(.scrollToPosition ^js inst offset))))) ;; Safe operaton, do nothing if node or offset has invalid values
(dom/scroll-to-position node offset)))))
[:div {:class [(stl/css-case :font-selector true [:div {:class [(stl/css-case :font-selector true
:fonts-on-modal (not full-size?))]} :fonts-on-modal (not full-size?))]}

View File

@ -388,20 +388,7 @@ $font-preview-box-height: 28px;
&.selected { &.selected {
color: var(--color-foreground-primary); color: var(--color-foreground-primary);
} }
}
.font-item-label {
@include t.use-typography("body-small");
@include text-ellipsis;
flex-grow: 1;
min-inline-size: 0;
}
// --- FLAG :font-preview row styling. Only applied when font-item* adds the
// .font-item-preview-on modifier; remove this whole block with the flag so rows
// render exactly as before.
.font-item-preview-on {
// Center & clip so a previewed font's own metrics never grow/overflow the row. // Center & clip so a previewed font's own metrics never grow/overflow the row.
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
@ -414,6 +401,14 @@ $font-preview-box-height: 28px;
} }
} }
.font-item-label {
@include t.use-typography("body-small");
@include text-ellipsis;
flex-grow: 1;
min-inline-size: 0;
}
// `currentColor` makes the glyph fill follow the row text color (theme + selected). // `currentColor` makes the glyph fill follow the row text color (theme + selected).
.font-item-preview { .font-item-preview {
flex-grow: 1; flex-grow: 1;

View File

@ -455,8 +455,8 @@
[:& editor-v2/text-editor {:shape editing-shape [:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref :canvas-ref canvas-ref
:modifiers modifiers}] :modifiers modifiers}]
[:> editor-v1/text-editor-svg* {:shape editing-shape [:& editor-v1/text-editor-svg {:shape editing-shape
:modifiers modifiers}])) :modifiers modifiers}]))
(when show-frame-outline? (when show-frame-outline?
(let [outlined-frame-id (let [outlined-frame-id

View File

@ -747,8 +747,8 @@
:canvas-ref canvas-ref :canvas-ref canvas-ref
:ref text-editor-ref}] :ref text-editor-ref}]
:else [:> editor-v1/text-editor-svg* {:shape editing-shape :else [:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}])) :ref text-editor-ref}]))
(when show-frame-outline? (when show-frame-outline?
(let [outlined-frame-id (->> @hover-ids (let [outlined-frame-id (->> @hover-ids

View File

@ -244,6 +244,16 @@
height (.-clientHeight scroll-node)] height (.-clientHeight scroll-node)]
(/ distance height))) (/ distance height)))
(defn scroll-to-row
[node index]
(when (and (some? node) (number? index))
(.scrollToRow ^js node index)))
(defn scroll-to-position
[node offset]
(when (and (some? node) (number? offset))
(.scrollToPosition ^js node offset)))
(def get-target-val (comp get-value get-target)) (def get-target-val (comp get-value get-target))
(def get-target-scroll (comp get-scroll-position get-target)) (def get-target-scroll (comp get-scroll-position get-target))

View File

@ -379,14 +379,14 @@
"float letter-spacing is normalised to 2-decimal string"))))))) "float letter-spacing is normalised to 2-decimal string")))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Tests: save-font must not persist typography refs into the global default font ;; Tests: save-default-font must not persist typography refs into the global default font
;; ;;
;; Root cause of #10925: typography assets are file-specific references, but ;; Root cause of #10925: typography assets are file-specific references, but
;; save-font used to write :typography-ref-id / :typography-ref-file into the ;; save-default-font used to write :typography-ref-id / :typography-ref-file into the
;; session-global [:workspace-global :default-font]. That state survives a file ;; session-global [:workspace-global :default-font]. That state survives a file
;; switch, and v2-default-text-content bakes it into brand-new text shapes in ;; switch, and v2-default-text-content bakes it into brand-new text shapes in
;; the other file, so they got a non-existent typography asset instead of the ;; the other file, so they got a non-existent typography asset instead of the
;; default Penpot font. save-font now strips those two keys. ;; default Penpot font. save-default-font now strips those two keys.
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(t/deftest save-font-strips-typography-refs-from-default-font (t/deftest save-font-strips-typography-refs-from-default-font
@ -405,7 +405,7 @@
:typography-ref-id (uuid/next) :typography-ref-id (uuid/next)
:typography-ref-file (:id file)}] :typography-ref-file (:id file)}]
(ths/run-store (ths/run-store
store done [(dwt/save-font attrs)] store done [(dwt/save-default-font attrs)]
(fn [new-state] (fn [new-state]
(let [default-font (get-in new-state [:workspace-global :default-font])] (let [default-font (get-in new-state [:workspace-global :default-font])]
(t/is (some? default-font)) (t/is (some? default-font))
@ -425,7 +425,7 @@
:letter-spacing "0" :letter-spacing "0"
:typography-ref-id (uuid/next) :typography-ref-id (uuid/next)
:typography-ref-file (uuid/next)}] :typography-ref-file (uuid/next)}]
(ths/run-store store done [(dwt/save-font attrs)] (ths/run-store store done [(dwt/save-default-font attrs)]
(fn [new-state] (fn [new-state]
(let [default-font (get-in new-state [:workspace-global :default-font])] (let [default-font (get-in new-state [:workspace-global :default-font])]
(t/is (= "Open Sans" (:font-family default-font))) (t/is (= "Open Sans" (:font-family default-font)))