diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 5f7041b654..2a905523b6 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -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) diff --git a/frontend/src/app/main/data/workspace/texts_v3.cljs b/frontend/src/app/main/data/workspace/texts_v3.cljs index 4f0834d7d2..7bda952571 100644 --- a/frontend/src/app/main/data/workspace/texts_v3.cljs +++ b/frontend/src/app/main/data/workspace/texts_v3.cljs @@ -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))))) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 761e1dbc6f..677f8aa1fb 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -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] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index b5a16d2f5d..68da4e80b4 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -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 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 00b8b08223..dae8e57b7b 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 @@ -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 diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index f434b024b9..03ee5353a1 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -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)