🐛 Fix invalid shape due to missing fonts (#10942)

* 🐛 Fix setting a font-related properties to nil

*  Repair already-corrupted text nodes

* 🐛 Fix deleted fonts appearing in Recents and as the auto-selected font for new text shapes
This commit is contained in:
Belén Albeza 2026-07-30 11:18:19 +02:00 committed by GitHub
parent 2cb8813862
commit be5d9f9e9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 93 additions and 19 deletions

View File

@ -145,6 +145,29 @@
;; -- Content helpers
;; Style attrs typed as `::sm/text` in the content schema (see
;; app.common.types.shape.text/schema:content): when the key is present its
;; value must be a non-blank string, so an explicit nil fails backend
;; `validate-shape`. `:key` is likewise a plain `:string`.
(def ^:private non-nilable-style-attrs
#{:font-family :font-size :font-style :font-weight
:direction :text-direction :text-decoration :text-transform :key})
(defn- remove-nil-style-attrs
"Strip nil-valued non-nilable style attrs from every node in a content tree.
Repairs content already corrupted with e.g. nil :font-family/:font-weight/
:font-style (from an unloaded font) so it can pass the backend schema again."
[content]
(txt/transform-nodes
(fn [node]
(reduce (fn [node k]
(if (and (contains? node k) (nil? (get node k)))
(dissoc node k)
node))
node
non-nilable-style-attrs))
content))
(defn ensure-valid-text-content
"Repair structurally incomplete text :content to a canonical
root -> paragraph-set -> paragraph -> span tree. Returns the
@ -153,7 +176,11 @@
A `nil` content, a root with no :children, or a root with an empty
:children vector all fail the backend `validate-shape` schema
(children must contain at least one paragraph-set). This helper
is the defensive normalizer used by content-commit paths."
is the defensive normalizer used by content-commit paths.
It also scrubs nil-valued non-nilable style attrs (e.g. nil
:font-family/:font-weight/:font-style left over from an unloaded font),
so already-corrupted content self-heals on the next commit."
[content]
(if (and (map? content)
(= "root" (:type content))
@ -161,7 +188,7 @@
(empty? (:children content))))
(let [base (tc/v2-default-text-content)]
(d/txt-merge base (select-keys content txt/root-attrs)))
content))
(remove-nil-style-attrs content)))
(defn- v2-content-has-text?
[content]
@ -294,7 +321,8 @@
(update [_ state]
(let [text-state (some->> content ted/import-content)
attrs (merge (txt/get-default-text-attrs)
(get-in state [:workspace-global :default-font]))
(fonts/valid-default-font
(get-in state [:workspace-global :default-font])))
editor (cond-> (ted/create-editor-state text-state decorator)
(and (nil? content) (some? attrs))
(ted/update-editor-current-block-data attrs))]
@ -1122,7 +1150,8 @@
;; Avoid swapping the global store when the computed styles are unchanged,
;; otherwise we can end up in store->rerender->selectionchange loops.
(let [merged-styles (merge (txt/get-default-text-attrs)
(get-in state [:workspace-global :default-font])
(fonts/valid-default-font
(get-in state [:workspace-global :default-font]))
new-styles)
prev (get-in state [:workspace-v2-editor-state id])]
(if (= merged-styles prev)

View File

@ -7,6 +7,7 @@
(ns app.main.data.workspace.texts-v3
(:require
[app.common.types.text :as txt]
[app.main.fonts :as fonts]
[potok.v2.core :as ptk]))
(defn v3-update-text-editor-styles
@ -15,6 +16,7 @@
ptk/UpdateEvent
(update [_ state]
(let [merged-styles (merge (txt/get-default-text-attrs)
(get-in state [:workspace-global :default-font])
(fonts/valid-default-font
(get-in state [:workspace-global :default-font]))
new-styles)]
(update-in state [:workspace-wasm-editor-styles id] (fnil merge {}) merged-styles)))))

View File

@ -73,6 +73,25 @@
(defn get-font-data [id]
(get @fontsdb id))
(defn installed?
"True when a font with `font-id` is currently registered in `fontsdb`."
[font-id]
(contains? @fontsdb font-id))
(defn valid-default-font
"Return the remembered default-font attrs only when its font is still
installed in `fontsdb`; otherwise nil, so callers fall back to the built-in
default text attrs (Source Sans Pro).
A new text shape inherits the last-used font (`[:workspace-global
:default-font]`). If that font is gone (deleted, unavailable in the current
team, or never resolved), its attrs carry a missing/nil font-family, which
produces content that fails the backend `validate-shape` schema."
[default-font]
(when (and (some? default-font)
(installed? (:font-id default-font)))
default-font))
(defn find-font-data [data]
(d/seek
(fn [font]

View File

@ -86,9 +86,11 @@
canvas-node
(mf/ref-val canvas-ref)
;; Gets the default font from the workspace refs.
;; Gets the default font from the workspace refs. Ignore it when the
;; remembered font is no longer installed, falling back to the built-in
;; default so a new text shape never inherits a missing font.
default-font
(deref refs/default-font)
(fonts/valid-default-font (deref refs/default-font))
style-defaults
(styles/get-style-defaults

View File

@ -178,15 +178,24 @@
flist (mf/use-ref)
input (mf/use-ref)
fonts (mf/deref fonts/fonts)
fonts (mf/with-memo [state fonts]
(filter-fonts state fonts))
all-fonts (mf/deref fonts/fonts)
fonts (mf/with-memo [state all-fonts]
(filter-fonts state all-fonts))
;; Ids currently installed in fontsdb. Recent fonts that are no longer
;; available (deleted, or belonging to another team) must be hidden:
;; selecting one applies a missing/nil font-family and corrupts the
;; text content (fails the backend `validate-shape` schema).
installed-ids (mf/with-memo [all-fonts]
(into #{} (map :id) all-fonts))
sprite-status (:status (mf/deref fonts/preview-sprite))
recent-fonts (mf/deref refs/recent-fonts)
recent-fonts (mf/with-memo [state recent-fonts]
(filter-fonts state recent-fonts))
recent-fonts (mf/with-memo [state recent-fonts installed-ids]
(->> recent-fonts
(filter #(contains? installed-ids (:id %)))
(filter-fonts state)))
full-size? (boolean (and full-size show-recent))
@ -332,12 +341,19 @@
(fn [new-font-id]
(let [{:keys [family] :as font} (get fonts new-font-id)
{:keys [id name weight style]} (fonts/get-default-variant font)]
(on-change {:font-id new-font-id
:font-family family
:font-variant-id (or id name)
:font-weight weight
:font-style style})
(mf/set-ref-val! last-font font))))
;; Guard against a font that is not present in fontsdb (unloaded
;; custom font, deleted font, shared library not yet resolved).
;; Without it `family`/`weight`/`style` come back nil and get written
;; onto the text spans, producing content that fails the backend
;; `validate-shape` schema (`:font-family`/`:font-weight`/`:font-style`
;; must be non-blank strings when present).
(when (and (some? font) (some? family))
(on-change {:font-id new-font-id
:font-family family
:font-variant-id (or id name)
:font-weight weight
:font-style style})
(mf/set-ref-val! last-font font)))))
on-font-size-change
(mf/use-fn

View File

@ -708,7 +708,13 @@
(defn apply-styles-to-selection
[attrs use-shape-fn set-shape-text-content-fn]
(when (wasm/ready?)
(let [shape-id (text-editor-get-active-shape-id)
(let [;; Drop nil-valued attrs so they are never merged onto text spans.
;; The DOM editor path strips these in `attrs->styles`; the WASM merge
;; here (`apply-attrs-to-paragraph`) does not, so an unresolved attr
;; (e.g. nil :font-family/:font-weight/:font-style from an unloaded
;; font) would corrupt the span and fail the backend schema.
attrs (into {} (remove (comp nil? val)) attrs)
shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
(when (and shape-id selection)